From 5223cbfa61853ab2a45a7a89a591463c81787a95 Mon Sep 17 00:00:00 2001 From: NKDuy Date: Thu, 4 Sep 2025 13:06:18 +0700 Subject: [PATCH 1/9] feat: prop `get` `set` new --- src/compiler/docs/generate-doc-data.ts | 6 + src/compiler/docs/test/markdown-props.spec.ts | 10 + .../convert-decorators.ts | 13 + .../decorators-to-static/prop-decorator.ts | 63 ++++- .../transformers/static-to-meta/props.ts | 2 + .../test/convert-decorators.spec.ts | 6 + .../transformers/test/parse-comments.spec.ts | 2 + .../transformers/test/parse-props.spec.ts | 259 ++++++++++++++++++ src/compiler/transformers/transform-utils.ts | 5 +- src/compiler/types/generate-prop-types.ts | 24 +- .../tests/ComponentCompilerProperty.stub.ts | 2 + .../types/tests/generate-prop-types.spec.ts | 60 +++- src/declarations/rindo-private.ts | 2 + src/declarations/rindo-public-docs.ts | 8 + src/runtime/proxy-component.ts | 137 ++++++--- src/runtime/test/attr.spec.tsx | 13 +- src/runtime/test/hydrate-prop-types.spec.tsx | 55 ++++ src/runtime/test/prop.spec.tsx | 76 ++++- src/runtime/test/watch.spec.tsx | 47 ++++ src/utils/constants.ts | 3 + src/utils/format-component-runtime-meta.ts | 6 + test/docs-json/docs.d.ts | 8 + test/end-to-end/src/app-root/app-root.e2e.ts | 8 +- test/end-to-end/src/app-root/app-root.tsx | 4 +- test/end-to-end/src/components.d.ts | 10 + test/end-to-end/src/prop-cmp/prop-cmp.e2e.ts | 33 ++- test/end-to-end/src/prop-cmp/prop-cmp.tsx | 15 +- test/end-to-end/src/prop-cmp/readme.md | 12 +- 28 files changed, 812 insertions(+), 77 deletions(-) diff --git a/src/compiler/docs/generate-doc-data.ts b/src/compiler/docs/generate-doc-data.ts index fc3968fc..076120d9 100644 --- a/src/compiler/docs/generate-doc-data.ts +++ b/src/compiler/docs/generate-doc-data.ts @@ -204,6 +204,9 @@ const getRealProperties = (properties: d.ComponentCompilerProperty[]): d.JsonDoc optional: member.optional, required: member.required, + + getter: member.getter, + setter: member.setter, })); }; @@ -227,6 +230,9 @@ const getVirtualProperties = (virtualProps: d.ComponentCompilerVirtualProperty[] optional: true, required: false, + + getter: undefined, + setter: undefined, })); }; diff --git a/src/compiler/docs/test/markdown-props.spec.ts b/src/compiler/docs/test/markdown-props.spec.ts index a7e6eb25..ba1e5435 100644 --- a/src/compiler/docs/test/markdown-props.spec.ts +++ b/src/compiler/docs/test/markdown-props.spec.ts @@ -15,6 +15,8 @@ describe('markdown props', () => { reflectToAttr: false, docsTags: [], values: [], + getter: false, + setter: false, }, { name: 'hello', @@ -28,6 +30,8 @@ describe('markdown props', () => { reflectToAttr: false, docsTags: [], values: [], + getter: false, + setter: false, }, ]).join('\n'); expect(markdown).toEqual(`## Properties @@ -54,6 +58,8 @@ describe('markdown props', () => { reflectToAttr: false, docsTags: [], values: [], + getter: false, + setter: false, }, ]).join('\n'); @@ -80,6 +86,8 @@ describe('markdown props', () => { reflectToAttr: false, docsTags: [], values: [], + getter: false, + setter: false, }, ]).join('\n'); @@ -106,6 +114,8 @@ describe('markdown props', () => { reflectToAttr: false, docsTags: [], values: [], + getter: false, + setter: false, }, ]).join('\n'); diff --git a/src/compiler/transformers/decorators-to-static/convert-decorators.ts b/src/compiler/transformers/decorators-to-static/convert-decorators.ts index 8ea24e77..d84de925 100644 --- a/src/compiler/transformers/decorators-to-static/convert-decorators.ts +++ b/src/compiler/transformers/decorators-to-static/convert-decorators.ts @@ -229,6 +229,19 @@ const removeRindoMethodDecorators = ( member.type, member.body, ); + } else if (ts.isGetAccessor(member)) { + return ts.factory.updateGetAccessorDeclaration( + member, + ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined, + member.name, + member.parameters, + member.type, + member.body, + ); + } else if (ts.isSetAccessor(member)) { + const err = buildError(diagnostics); + err.messageText = 'A get accessor should be decorated before a set accessor'; + augmentDiagnosticWithNode(err, member); } else if (ts.isPropertyDeclaration(member)) { if (shouldInitializeInConstructor(member, importAliasMap)) { // if the current class member is decorated with either 'State' or diff --git a/src/compiler/transformers/decorators-to-static/prop-decorator.ts b/src/compiler/transformers/decorators-to-static/prop-decorator.ts index 24c0211c..4f42f318 100644 --- a/src/compiler/transformers/decorators-to-static/prop-decorator.ts +++ b/src/compiler/transformers/decorators-to-static/prop-decorator.ts @@ -38,8 +38,8 @@ export const propDecoratorsToStatic = ( decoratorName: string, ): void => { const properties = decoratedProps - .filter(ts.isPropertyDeclaration) - .map((prop) => parsePropDecorator(diagnostics, typeChecker, program, prop, decoratorName)) + .filter((prop) => ts.isPropertyDeclaration(prop) || ts.isGetAccessor(prop)) + .map((prop) => parsePropDecorator(diagnostics, typeChecker, program, prop, decoratorName, newMembers)) .filter((prop): prop is ts.PropertyAssignment => prop != null); if (properties.length > 0) { @@ -55,14 +55,16 @@ export const propDecoratorsToStatic = ( * @param program a {@link ts.Program} object * @param prop the TypeScript `PropertyDeclaration` to parse * @param decoratorName the name of the decorator to look for + * @param newMembers a collection of parsed `@Prop` annotated class members. Used for `get()` decorated props to find a corresponding `set()` * @returns a property assignment expression to be added to the Rindo component's class */ const parsePropDecorator = ( diagnostics: d.Diagnostic[], typeChecker: ts.TypeChecker, program: ts.Program, - prop: ts.PropertyDeclaration, + prop: ts.PropertyDeclaration | ts.GetAccessorDeclaration, decoratorName: string, + newMembers: ts.ClassElement[], ): ts.PropertyAssignment | null => { const propDecorator = retrieveTsDecorators(prop)?.find(isDecoratorNamed(decoratorName)); if (propDecorator == null) { @@ -92,6 +94,7 @@ const parsePropDecorator = ( const symbol = typeChecker.getSymbolAtLocation(prop.name); const type = typeChecker.getTypeAtLocation(prop); const typeStr = propTypeFromTSType(type); + const foundSetter = ts.isGetAccessor(prop) ? findSetter(propName, newMembers) : null; const propMeta: d.ComponentCompilerStaticProperty = { type: typeStr, @@ -100,6 +103,8 @@ const parsePropDecorator = ( required: prop.exclamationToken !== undefined && propName !== 'mode', optional: prop.questionToken !== undefined, docs: serializeSymbol(typeChecker, symbol), + getter: ts.isGetAccessor(prop), + setter: !!foundSetter, }; // prop can have an attribute if type is NOT "unknown" @@ -109,9 +114,30 @@ const parsePropDecorator = ( } // extract default value - const initializer = prop.initializer; - if (initializer) { - propMeta.defaultValue = initializer.getText(); + if (ts.isPropertyDeclaration(prop) && prop.initializer) { + propMeta.defaultValue = prop.initializer.getText(); + } else if (ts.isGetAccessorDeclaration(prop)) { + // shallow comb to find default value for a getter + const returnStatement = prop.body?.statements.find((st) => ts.isReturnStatement(st)) as ts.ReturnStatement; + const returnExpression = returnStatement.expression; + + if (returnExpression && ts.isLiteralExpression(returnExpression)) { + // the getter has a literal return value + propMeta.defaultValue = returnExpression.getText(); + } else if (returnExpression && ts.isPropertyAccessExpression(returnExpression)) { + const nameToFind = returnExpression.name.getText(); + const foundProp = findGetProp(nameToFind, newMembers); + + if (foundProp && foundProp.initializer) { + propMeta.defaultValue = foundProp.initializer.getText(); + + if (propMeta.type === 'unknown') { + const type = typeChecker.getTypeAtLocation(foundProp); + propMeta.type = propTypeFromTSType(type); + propMeta.complexType = getComplexType(typeChecker, foundProp, type, program); + } + } + } } const staticProp = ts.factory.createPropertyAssignment( @@ -164,7 +190,7 @@ const getReflect = (diagnostics: d.Diagnostic[], propDecorator: ts.Decorator, pr const getComplexType = ( typeChecker: ts.TypeChecker, - node: ts.PropertyDeclaration, + node: ts.PropertyDeclaration | ts.GetAccessorDeclaration, type: ts.Type, program: ts.Program, ): d.ComponentCompilerPropertyComplexType => { @@ -293,3 +319,26 @@ const isAny = (t: ts.Type): boolean => { } return false; }; + +/** + * Attempts to find a `set` member of the class when there is a corresponding getter + * @param propName - the property name of the setter to find + * @param members - all the component class members + * @returns the found typescript AST setter node + */ +const findSetter = (propName: string, members: ts.ClassElement[]): ts.SetAccessorDeclaration | undefined => { + return members.find((m) => ts.isSetAccessor(m) && m.name.getText() === propName) as + | ts.SetAccessorDeclaration + | undefined; +}; + +/** + * When attempting to find the default value of a decorated `get` prop, if a member like `this.something` + * is returned, this method is used to comb the class members to attempt to get it's default value + * @param propName - the property name of the member to find + * @param members - all the component class members + * @returns the found typescript AST class member + */ +const findGetProp = (propName: string, members: ts.ClassElement[]): ts.PropertyDeclaration | undefined => { + return members.find((m) => ts.isPropertyDeclaration(m) && m.name.getText() === propName) as ts.PropertyDeclaration; +}; diff --git a/src/compiler/transformers/static-to-meta/props.ts b/src/compiler/transformers/static-to-meta/props.ts index 92586208..087704e5 100644 --- a/src/compiler/transformers/static-to-meta/props.ts +++ b/src/compiler/transformers/static-to-meta/props.ts @@ -37,6 +37,8 @@ export const parseStaticProps = (staticMembers: ts.ClassElement[]): d.ComponentC complexType: val.complexType, docs: val.docs, internal: isInternal(val.docs), + getter: !!val.getter, + setter: !!val.setter, }; }); }; diff --git a/src/compiler/transformers/test/convert-decorators.spec.ts b/src/compiler/transformers/test/convert-decorators.spec.ts index 44ab03e5..f85dd5dc 100644 --- a/src/compiler/transformers/test/convert-decorators.spec.ts +++ b/src/compiler/transformers/test/convert-decorators.spec.ts @@ -33,6 +33,8 @@ describe('convert-decorators', () => { "required": false, "optional": false, "docs": { "tags": [], "text": "" }, + "getter": false, + "setter": false, "attribute": "val", "reflect": false, "defaultValue": "\\"initial value\\"" @@ -84,6 +86,8 @@ describe('convert-decorators', () => { complexType: { original: 'string', resolved: 'string', references: {} }, docs: { tags: [], text: '' }, internal: false, + getter: false, + setter: false, }, ]); }); @@ -110,6 +114,8 @@ describe('convert-decorators', () => { complexType: { original: 'string', resolved: 'string', references: {} }, docs: { tags: [], text: '' }, internal: false, + getter: false, + setter: false, }, ]); }); diff --git a/src/compiler/transformers/test/parse-comments.spec.ts b/src/compiler/transformers/test/parse-comments.spec.ts index 66565515..27cf55d9 100644 --- a/src/compiler/transformers/test/parse-comments.spec.ts +++ b/src/compiler/transformers/test/parse-comments.spec.ts @@ -56,6 +56,8 @@ describe('parse comments', () => { reflect: false, required: false, type: 'string', + getter: false, + setter: false, }); expect(t.method).toEqual({ complexType: { diff --git a/src/compiler/transformers/test/parse-props.spec.ts b/src/compiler/transformers/test/parse-props.spec.ts index 9bb89e97..3dda4719 100644 --- a/src/compiler/transformers/test/parse-props.spec.ts +++ b/src/compiler/transformers/test/parse-props.spec.ts @@ -25,6 +25,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'string', + getter: false, + setter: false, }, }); @@ -66,6 +68,8 @@ describe('parse props', () => { }, docs: { tags: [], text: '' }, internal: false, + getter: false, + setter: false, }); }); @@ -98,6 +102,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'any', + getter: false, + setter: false, }, }); }); @@ -126,6 +132,8 @@ describe('parse props', () => { reflect: false, required: true, type: 'string', + getter: false, + setter: false, }, }); expect(t.property?.required).toBe(true); @@ -156,6 +164,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'string', + getter: false, + setter: false, }, }); expect(t.property?.mutable).toBe(true); @@ -185,6 +195,8 @@ describe('parse props', () => { reflect: true, required: false, type: 'string', + getter: false, + setter: false, }, }); expect(t.property?.reflect).toBe(true); @@ -213,6 +225,8 @@ describe('parse props', () => { optional: false, required: false, type: 'unknown', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('unknown'); @@ -249,6 +263,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'any', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('any'); @@ -281,6 +297,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'string', + getter: false, + setter: false, }, }); expect(t.property?.name).toBe('multiWord'); @@ -311,6 +329,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'string', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('string'); @@ -341,6 +361,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'number', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('number'); @@ -371,6 +393,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'boolean', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('boolean'); @@ -401,6 +425,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'any', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('any'); @@ -432,6 +458,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'string', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('string'); @@ -463,6 +491,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'number', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('number'); @@ -494,6 +524,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'boolean', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('boolean'); @@ -526,9 +558,236 @@ describe('parse props', () => { reflect: false, required: false, type: 'any', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('any'); expect(t.property?.attribute).toBe('val'); }); + + it('should infer string type from `get()` return value', () => { + const t = transpileModule(` + @Component({tag: 'cmp-a'}) + export class CmpA { + @Prop() + get val() { + return 'hello'; + }; + } + `); + + expect(getStaticGetter(t.outputText, 'properties')).toEqual({ + val: { + attribute: 'val', + complexType: { + references: {}, + resolved: 'string', + original: 'string', + }, + docs: { + text: '', + tags: [], + }, + defaultValue: `'hello'`, + mutable: false, + optional: false, + reflect: false, + required: false, + type: 'string', + getter: true, + setter: false, + }, + }); + expect(t.property?.type).toBe('string'); + expect(t.property?.attribute).toBe('val'); + }); + + it('should infer number type from `get()` property access expression', () => { + const t = transpileModule(` + @Component({tag: 'cmp-a'}) + export class CmpA { + private _numberVal = 3; + @Prop() + get val() { + return this._numberVal; + }; + } + `); + + expect(getStaticGetter(t.outputText, 'properties')).toEqual({ + val: { + attribute: 'val', + complexType: { + references: {}, + resolved: 'number', + original: 'number', + }, + docs: { + text: '', + tags: [], + }, + defaultValue: `3`, + mutable: false, + optional: false, + reflect: false, + required: false, + type: 'number', + getter: true, + setter: false, + }, + }); + expect(t.property?.type).toBe('number'); + expect(t.property?.attribute).toBe('val'); + }); + + it('should infer boolean type from `get()` property access expression', () => { + const t = transpileModule(` + @Component({tag: 'cmp-a'}) + export class CmpA { + private _boolVal = false; + @Prop() + get val() { + return this._boolVal; + }; + } + `); + + expect(getStaticGetter(t.outputText, 'properties')).toEqual({ + val: { + attribute: 'val', + complexType: { + references: {}, + resolved: 'boolean', + original: 'boolean', + }, + docs: { + text: '', + tags: [], + }, + defaultValue: `false`, + mutable: false, + optional: false, + reflect: false, + required: false, + type: 'boolean', + getter: true, + setter: false, + }, + }); + expect(t.property?.type).toBe('boolean'); + expect(t.property?.attribute).toBe('val'); + }); + + it('should correctly parse a get / set prop with an inferred enum type', () => { + const t = transpileModule(` + export enum Mode { + DEFAULT = 'default' + } + @Component({tag: 'cmp-a'}) + export class CmpA { + private _val: Mode; + @Prop() + get val() { + return this._val; + }; + } + `); + + // Using the `properties` array directly here since the `transpileModule` + // method doesn't like the top-level enum export with the current `target` and + // `module` values for the tsconfig + expect(t.properties[0]).toEqual({ + name: 'val', + type: 'string', + attribute: 'val', + reflect: false, + mutable: false, + required: false, + optional: false, + defaultValue: undefined, + complexType: { + original: 'Mode', + resolved: 'Mode', + references: { + Mode: { location: 'local', path: 'module.tsx', id: 'module.tsx::Mode' }, + }, + }, + docs: { tags: [], text: '' }, + internal: false, + getter: true, + setter: false, + }); + }); + + it('should correctly parse a get / set prop with an inferred literal type', () => { + const t = transpileModule(` + @Component({tag: 'cmp-a'}) + export class CmpA { + private _val: 'Something' | 'Else' = 'Something'; + @Prop() + get val() { + return this._val; + }; + } + `); + + expect(t.properties[0]).toEqual({ + name: 'val', + type: 'string', + attribute: 'val', + reflect: false, + mutable: false, + required: false, + optional: false, + defaultValue: "'Something'", + complexType: { + original: '"Something" | "Else"', + resolved: '"Else" | "Something"', + references: {}, + }, + docs: { tags: [], text: '' }, + internal: false, + getter: true, + setter: false, + }); + }); + + it('should not infer type from `get()` property access expression when getter type is explicit', () => { + const t = transpileModule(` + @Component({tag: 'cmp-a'}) + export class CmpA { + private _boolVal: boolean = false; + @Prop() + get val(): string { + return this._boolVal; + }; + } + `); + + expect(getStaticGetter(t.outputText, 'properties')).toEqual({ + val: { + attribute: 'val', + complexType: { + references: {}, + resolved: 'string', + original: 'string', + }, + docs: { + text: '', + tags: [], + }, + defaultValue: `false`, + mutable: false, + optional: false, + reflect: false, + required: false, + type: 'string', + getter: true, + setter: false, + }, + }); + expect(t.property?.type).toBe('string'); + expect(t.property?.attribute).toBe('val'); + }); }); diff --git a/src/compiler/transformers/transform-utils.ts b/src/compiler/transformers/transform-utils.ts index a9536817..705b7c77 100644 --- a/src/compiler/transformers/transform-utils.ts +++ b/src/compiler/transformers/transform-utils.ts @@ -1049,7 +1049,10 @@ const createConstructorBodyWithSuper = (): ts.ExpressionStatement => { * @param typeChecker a reference to the {@link ts.TypeChecker} * @returns the name of the property in string form */ -export const tsPropDeclNameAsString = (node: ts.PropertyDeclaration, typeChecker: ts.TypeChecker): string => { +export const tsPropDeclNameAsString = ( + node: ts.PropertyDeclaration | ts.GetAccessorDeclaration, + typeChecker: ts.TypeChecker, +): string => { const declarationName: ts.DeclarationName = ts.getNameOfDeclaration(node); // The name of a class field declaration can be a computed property name, diff --git a/src/compiler/types/generate-prop-types.ts b/src/compiler/types/generate-prop-types.ts index ff846b19..f758d4eb 100644 --- a/src/compiler/types/generate-prop-types.ts +++ b/src/compiler/types/generate-prop-types.ts @@ -11,14 +11,22 @@ import { updateTypeIdentifierNames } from './rindo-types'; */ export const generatePropTypes = (cmpMeta: d.ComponentCompilerMeta, typeImportData: d.TypesImportData): d.TypeInfo => { return [ - ...cmpMeta.properties.map((cmpProp) => ({ - name: cmpProp.name, - type: getType(cmpProp, typeImportData, cmpMeta.sourceFilePath), - optional: cmpProp.optional, - required: cmpProp.required, - internal: cmpProp.internal, - jsdoc: getTextDocs(cmpProp.docs), - })), + ...cmpMeta.properties.map((cmpProp) => { + let doc = getTextDocs(cmpProp.docs); + if (cmpProp.getter && !cmpProp.setter && !doc?.match('@readonly')) { + cmpProp.docs = cmpProp.docs || { tags: [], text: '' }; + cmpProp.docs.tags = [...(cmpProp.docs.tags || []), { name: 'readonly', text: '' }]; + doc = getTextDocs(cmpProp.docs); + } + return { + name: cmpProp.name, + type: getType(cmpProp, typeImportData, cmpMeta.sourceFilePath), + optional: cmpProp.optional, + required: cmpProp.required, + internal: cmpProp.internal, + jsdoc: doc, + }; + }), ...cmpMeta.virtualProperties.map((cmpProp) => ({ name: cmpProp.name, type: cmpProp.type, diff --git a/src/compiler/types/tests/ComponentCompilerProperty.stub.ts b/src/compiler/types/tests/ComponentCompilerProperty.stub.ts index a0e4c67e..b71509e7 100644 --- a/src/compiler/types/tests/ComponentCompilerProperty.stub.ts +++ b/src/compiler/types/tests/ComponentCompilerProperty.stub.ts @@ -31,6 +31,8 @@ export const stubComponentCompilerProperty = ( reflect: false, required: false, type: 'number', + getter: undefined, + setter: undefined, }; return { ...defaults, ...overrides }; diff --git a/src/compiler/types/tests/generate-prop-types.spec.ts b/src/compiler/types/tests/generate-prop-types.spec.ts index dffec779..8da8ef4f 100644 --- a/src/compiler/types/tests/generate-prop-types.spec.ts +++ b/src/compiler/types/tests/generate-prop-types.spec.ts @@ -1,5 +1,4 @@ import type * as d from '../../../declarations'; -import * as Util from '../../../utils/util'; import { generatePropTypes } from '../generate-prop-types'; import * as RindoTypes from '../rindo-types'; import { stubComponentCompilerMeta } from './ComponentCompilerMeta.stub'; @@ -13,7 +12,6 @@ describe('generate-prop-types', () => { ReturnType, Parameters >; - let getTextDocsSpy: jest.SpyInstance, Parameters>; beforeEach(() => { updateTypeIdentifierNamesSpy = jest.spyOn(RindoTypes, 'updateTypeIdentifierNames'); @@ -25,14 +23,10 @@ describe('generate-prop-types', () => { initialType: string, ) => initialType, ); - - getTextDocsSpy = jest.spyOn(Util, 'getTextDocs'); - getTextDocsSpy.mockReturnValue(''); }); afterEach(() => { updateTypeIdentifierNamesSpy.mockRestore(); - getTextDocsSpy.mockRestore(); }); it('returns an empty array when no props are provided', () => { @@ -141,5 +135,59 @@ describe('generate-prop-types', () => { expect(actualTypeInfo).toEqual(expectedTypeInfo); }); + + it('appends `@readonly` to jsdoc when the property has a getter and no setter', () => { + const stubImportTypes = stubTypesImportData(); + const componentMeta = stubComponentCompilerMeta({ + properties: [ + stubComponentCompilerProperty({ + getter: true, + setter: false, + }), + ], + }); + + const expectedTypeInfo: d.TypeInfo = [ + { + jsdoc: '@readonly', + internal: false, + name: 'propName', + optional: false, + required: false, + type: 'UserCustomPropType', + }, + ]; + + const actualTypeInfo = generatePropTypes(componentMeta, stubImportTypes); + + expect(actualTypeInfo).toEqual(expectedTypeInfo); + }); + + it('does not include `@readonly` to jsdoc when the property has a getter and a setter', () => { + const stubImportTypes = stubTypesImportData(); + const componentMeta = stubComponentCompilerMeta({ + properties: [ + stubComponentCompilerProperty({ + getter: true, + setter: true, + }), + ], + }); + + const expectedTypeInfo: d.TypeInfo = [ + { + jsdoc: '', + internal: false, + name: 'propName', + optional: false, + required: false, + type: 'UserCustomPropType', + }, + ]; + + const actualTypeInfo = generatePropTypes(componentMeta, stubImportTypes); + + expect(actualTypeInfo).toEqual(expectedTypeInfo); + }); }); }); diff --git a/src/declarations/rindo-private.ts b/src/declarations/rindo-private.ts index 304704e6..a852c4e9 100644 --- a/src/declarations/rindo-private.ts +++ b/src/declarations/rindo-private.ts @@ -684,6 +684,8 @@ export interface ComponentCompilerStaticProperty { reflect?: boolean; docs: CompilerJsDoc; defaultValue?: string; + getter: boolean; + setter: boolean; } /** diff --git a/src/declarations/rindo-public-docs.ts b/src/declarations/rindo-public-docs.ts index 3184586f..1be03b1b 100644 --- a/src/declarations/rindo-public-docs.ts +++ b/src/declarations/rindo-public-docs.ts @@ -264,6 +264,14 @@ export interface JsonDocsProp { * ``` */ required: boolean; + /** + * `true` if the prop has a `get()`. `false` otherwise + */ + getter: boolean; + /** + * `true` if the prop has a `set()`. `false` otherwise + */ + setter: boolean; } export interface JsonDocsMethod { diff --git a/src/runtime/proxy-component.ts b/src/runtime/proxy-component.ts index e1c491c1..da75e771 100644 --- a/src/runtime/proxy-component.ts +++ b/src/runtime/proxy-component.ts @@ -1,5 +1,5 @@ import { BUILD } from '@app-data'; -import { consoleDevWarn, getHostRef, plt } from '@platform'; +import { consoleDevWarn, getHostRef, parsePropertyValue, plt } from '@platform'; import { CMP_FLAGS } from '@utils'; import type * as d from '../declarations'; @@ -62,37 +62,105 @@ export const proxyComponent = ( (memberFlags & MEMBER_FLAGS.Prop || ((!BUILD.lazyLoad || flags & PROXY_FLAGS.proxyState) && memberFlags & MEMBER_FLAGS.State)) ) { - // proxyComponent - prop - Object.defineProperty(prototype, memberName, { - get(this: d.RuntimeRef) { - // proxyComponent, get value - return getValue(this, memberName); - }, - set(this: d.RuntimeRef, newValue) { - // only during dev time - if (BUILD.isDev) { - const ref = getHostRef(this); - if ( - // we are proxying the instance (not element) - (flags & PROXY_FLAGS.isElementConstructor) === 0 && - // the element is not constructing - (ref && ref.$flags$ & HOST_FLAGS.isConstructingInstance) === 0 && - // the member is a prop - (memberFlags & MEMBER_FLAGS.Prop) !== 0 && - // the member is not mutable - (memberFlags & MEMBER_FLAGS.Mutable) === 0 - ) { - consoleDevWarn( - `@Prop() "${memberName}" on <${cmpMeta.$tagName$}> is immutable but was modified from within the component.\nMore information: https://rindojs.web.app/docs/properties#prop-mutability`, - ); + if ((memberFlags & MEMBER_FLAGS.Getter) === 0) { + // proxyComponent - prop + Object.defineProperty(prototype, memberName, { + get(this: d.RuntimeRef) { + // proxyComponent, get value + return getValue(this, memberName); + }, + set(this: d.RuntimeRef, newValue) { + // only during dev time + if (BUILD.isDev) { + const ref = getHostRef(this); + if ( + // we are proxying the instance (not element) + (flags & PROXY_FLAGS.isElementConstructor) === 0 && + // the element is not constructing + (ref && ref.$flags$ & HOST_FLAGS.isConstructingInstance) === 0 && + // the member is a prop + (memberFlags & MEMBER_FLAGS.Prop) !== 0 && + // the member is not mutable + (memberFlags & MEMBER_FLAGS.Mutable) === 0 + ) { + consoleDevWarn( + `@Prop() "${memberName}" on <${cmpMeta.$tagName$}> is immutable but was modified from within the component.\nMore information: https://rindojs.web.app/docs/properties#prop-mutability`, + ); + } } - } - // proxyComponent, set value - setValue(this, memberName, newValue, cmpMeta); - }, - configurable: true, - enumerable: true, - }); + // proxyComponent, set value + setValue(this, memberName, newValue, cmpMeta); + }, + configurable: true, + enumerable: true, + }); + } else if (flags & PROXY_FLAGS.isElementConstructor && memberFlags & MEMBER_FLAGS.Getter) { + if (BUILD.lazyLoad) { + // lazily maps the element get / set to the class get / set + // proxyComponent - lazy prop getter + Object.defineProperty(prototype, memberName, { + get(this: d.RuntimeRef) { + const ref = getHostRef(this); + const instance = BUILD.lazyLoad && ref ? ref.$lazyInstance$ : prototype; + if (!instance) return; + + return instance[memberName]; + }, + configurable: true, + enumerable: true, + }); + } + if (memberFlags & MEMBER_FLAGS.Setter) { + // proxyComponent - lazy and non-lazy. Catches original set to fire updates (for @Watch) + const origSetter = Object.getOwnPropertyDescriptor(prototype, memberName).set; + Object.defineProperty(prototype, memberName, { + set(this: d.RuntimeRef, newValue) { + // non-lazy setter - amends original set to fire update + const ref = getHostRef(this); + if (origSetter) { + const currentValue = ref.$hostElement$[memberName as keyof d.HostElement]; + if (!ref.$instanceValues$.get(memberName) && currentValue) { + // the prop `set()` doesn't fire during `constructor()`: + // no initial value gets set (in instanceValues) + // meaning watchers fire even though the value hasn't changed. + // So if there's a current value and no initial value, let's set it now. + ref.$instanceValues$.set(memberName, currentValue); + } + // this sets the value via the `set()` function which + // might not end up changing the underlying value + origSetter.apply(this, [parsePropertyValue(newValue, cmpMeta.$members$[memberName][0])]); + setValue(this, memberName, ref.$hostElement$[memberName as keyof d.HostElement], cmpMeta); + return; + } + if (!ref) return; + + // we need to wait for the lazy instance to be ready + // before we can set it's value via it's setter function + const setterSetVal = () => { + const currentValue = ref.$lazyInstance$[memberName]; + if (!ref.$instanceValues$.get(memberName) && currentValue) { + // the prop `set()` doesn't fire during `constructor()`: + // no initial value gets set (in instanceValues) + // meaning watchers fire even though the value hasn't changed. + // So if there's a current value and no initial value, let's set it now. + ref.$instanceValues$.set(memberName, currentValue); + } + // this sets the value via the `set()` function which + // might not end up changing the underlying value + ref.$lazyInstance$[memberName] = parsePropertyValue(newValue, cmpMeta.$members$[memberName][0]); + setValue(this, memberName, ref.$lazyInstance$[memberName], cmpMeta); + }; + + // If there's a value from an attribute, (before the class is defined), queue & set async + if (ref.$lazyInstance$) { + setterSetVal(); + } else { + ref.$onReadyPromise$.then(() => setterSetVal()); + } + }, + }); + } + } } else if ( BUILD.lazyLoad && BUILD.method && @@ -191,7 +259,12 @@ export const proxyComponent = ( return; } - this[propName] = newValue === null && typeof this[propName] === 'boolean' ? false : newValue; + const propDesc = Object.getOwnPropertyDescriptor(prototype, propName); + // test whether this property either has no 'getter' or if it does, does it also have a 'setter' + // before attempting to write back to component props + if (!propDesc.get || !!propDesc.set) { + this[propName] = newValue === null && typeof this[propName] === 'boolean' ? false : newValue; + } }); }; diff --git a/src/runtime/test/attr.spec.tsx b/src/runtime/test/attr.spec.tsx index 74481c80..4a4d6085 100644 --- a/src/runtime/test/attr.spec.tsx +++ b/src/runtime/test/attr.spec.tsx @@ -262,6 +262,14 @@ describe('attribute', () => { @Prop({ reflect: true, mutable: true }) dynamicStr: string; @Prop({ reflect: true }) dynamicNu: number; + private _getset = 'prop via getter'; + @Prop({ reflect: true }) + get getSet() { + return this._getset; + } + set getSet(newVal: string) { + this._getset = newVal; + } componentWillLoad() { this.dynamicStr = 'value'; @@ -275,7 +283,7 @@ describe('attribute', () => { }); expect(root).toEqualHtml(` - + `); root.str = 'second'; @@ -284,11 +292,12 @@ describe('attribute', () => { root.null = 'no null'; root.bool = true; root.otherBool = false; + root.getSet = 'prop set via setter'; await waitForChanges(); expect(root).toEqualHtml(` - + `); }); diff --git a/src/runtime/test/hydrate-prop-types.spec.tsx b/src/runtime/test/hydrate-prop-types.spec.tsx index 73c41225..026e65ad 100644 --- a/src/runtime/test/hydrate-prop-types.spec.tsx +++ b/src/runtime/test/hydrate-prop-types.spec.tsx @@ -48,4 +48,59 @@ describe('hydrate prop types', () => { `); }); + + it('handles getters and setters', async () => { + @Component({ tag: 'cmp-a' }) + class CmpA { + _num = 0; + + @Prop() + get num() { + return this._num; + } + set num(value) { + this._num = value; + } + + componentWillRender() { + if (this.num < 100) { + this.num += 100; + } + } + + render() { + return {this.num}; + } + } + // @ts-ignore + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ``, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + 101 + + `); + + // @ts-ignore + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + expect(clientHydrated.root['s-id']).toBe('1'); + expect(clientHydrated.root['s-cr'].nodeType).toBe(8); + expect(clientHydrated.root['s-cr']['s-cn']).toBe(true); + + expect(clientHydrated.root).toEqualHtml(` + + + 101 + + `); + }); }); diff --git a/src/runtime/test/prop.spec.tsx b/src/runtime/test/prop.spec.tsx index bb2d3cdf..f7574cbb 100644 --- a/src/runtime/test/prop.spec.tsx +++ b/src/runtime/test/prop.spec.tsx @@ -31,27 +31,36 @@ describe('prop', () => { @Prop() boolTrue = true; @Prop() str = 'string'; @Prop() num = 88; + private _accessor = 'accessor'; + @Prop() + get accessor() { + return this._accessor; + } + set accessor(newVal) { + this._accessor = newVal; + } render() { - return `${this.boolFalse}-${this.boolTrue}-${this.str}-${this.num}`; + return `${this.boolFalse}-${this.boolTrue}-${this.str}-${this.num}-${this.accessor}`; } } const { root } = await newSpecPage({ components: [CmpA], - html: ``, + html: ``, }); expect(root).toEqualHtml(` - - true-false-attr-99 + + true-false-attr-99-accessed! `); - expect(root.textContent).toBe('true-false-attr-99'); + expect(root.textContent).toBe('true-false-attr-99-accessed!'); expect(root.boolFalse).toBe(true); expect(root.boolTrue).toBe(false); expect(root.str).toBe('attr'); expect(root.num).toBe(99); + expect(root.accessor).toBe('accessed!'); }); it('set default values', async () => { @@ -61,8 +70,16 @@ describe('prop', () => { @Prop() boolTrue = true; @Prop() str = 'string'; @Prop() num = 88; + private _accessor = 'accessor'; + @Prop() + get accessor() { + return this._accessor; + } + set accessor(newVal) { + this._accessor = newVal; + } render() { - return `${this.boolFalse}-${this.boolTrue}-${this.str}-${this.num}`; + return `${this.boolFalse}-${this.boolTrue}-${this.str}-${this.num}-${this.accessor}`; } } @@ -72,14 +89,15 @@ describe('prop', () => { }); expect(root).toEqualHtml(` - false-true-string-88 + false-true-string-88-accessor `); - expect(root.textContent).toBe('false-true-string-88'); + expect(root.textContent).toBe('false-true-string-88-accessor'); expect(root.boolFalse).toBe(false); expect(root.boolTrue).toBe(true); expect(root.str).toBe('string'); expect(root.num).toBe(88); + expect(root.accessor).toBe('accessor'); }); it('only update on even numbers', async () => { @@ -123,4 +141,46 @@ describe('prop', () => { 4 `); }); + + it('only updates on even numbers via a setter', async () => { + @Component({ tag: 'cmp-a' }) + class CmpA { + private _num = 1; + @Prop() + get num() { + return this._num; + } + set num(newValue: number) { + if (newValue % 2 === 0) this._num = newValue; + } + render() { + return `${this.num}`; + } + } + + const { root, waitForChanges } = await newSpecPage({ + components: [CmpA], + html: ``, + }); + + expect(root).toEqualHtml(` + 1 + `); + + root.num = 2; + await waitForChanges(); + expect(root).toEqualHtml(` + 2 + `); + root.num = 3; + await waitForChanges(); + expect(root).toEqualHtml(` + 2 + `); + root.num = 4; + await waitForChanges(); + expect(root).toEqualHtml(` + 4 + `); + }); }); diff --git a/src/runtime/test/watch.spec.tsx b/src/runtime/test/watch.spec.tsx index 280514ba..b3c06dc4 100644 --- a/src/runtime/test/watch.spec.tsx +++ b/src/runtime/test/watch.spec.tsx @@ -174,4 +174,51 @@ describe('watch', () => { await waitForChanges(); expect(root).toEqualHtml(`3 5 5`); }); + + it('correctly calls watch when @Prop uses `set()', async () => { + @Component({ tag: 'cmp-a' }) + class CmpA { + method1Called = 0; + + private _prop1 = 1; + @Prop() + get prop1() { + return this._prop1; + } + set prop1(newProp: number) { + if (typeof newProp !== 'number') return; + this._prop1 = newProp; + } + + @Watch('prop1') + method1() { + this.method1Called++; + } + + componentDidLoad() { + expect(this.method1Called).toBe(0); + expect(this.prop1).toBe(1); + } + } + + const { root, rootInstance } = await newSpecPage({ + components: [CmpA], + html: ``, + }); + jest.spyOn(rootInstance, 'method1'); + + // set same values, watch should not be called + root.prop1 = 1; + expect(rootInstance.method1).toHaveBeenCalledTimes(0); + + // set different values + root.prop1 = 100; + expect(rootInstance.method1).toHaveBeenCalledTimes(1); + expect(rootInstance.method1).toHaveBeenLastCalledWith(100, 1, 'prop1'); + + // guard has prevented the watch from being called + rootInstance.prop1 = 'bye'; + expect(rootInstance.method1).toHaveBeenCalledTimes(1); + expect(rootInstance.method1).toHaveBeenLastCalledWith(100, 1, 'prop1'); + }); }); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 15d0cba6..9b7f2bf3 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -13,6 +13,9 @@ export const enum MEMBER_FLAGS { ReflectAttr = 1 << 9, Mutable = 1 << 10, + Getter = 1 << 11, + Setter = 1 << 12, + Prop = String | Number | Boolean | Any | Unknown, HasAttribute = String | Number | Boolean | Any, PropLike = Prop | State, diff --git a/src/utils/format-component-runtime-meta.ts b/src/utils/format-component-runtime-meta.ts index eb67a803..12bbf293 100644 --- a/src/utils/format-component-runtime-meta.ts +++ b/src/utils/format-component-runtime-meta.ts @@ -114,6 +114,12 @@ const formatFlags = (compilerProperty: d.ComponentCompilerProperty) => { if (compilerProperty.reflect) { type |= MEMBER_FLAGS.ReflectAttr; } + if (compilerProperty.getter) { + type |= MEMBER_FLAGS.Getter; + } + if (compilerProperty.setter) { + type |= MEMBER_FLAGS.Setter; + } return type; }; diff --git a/test/docs-json/docs.d.ts b/test/docs-json/docs.d.ts index b59bf864..a74dc987 100644 --- a/test/docs-json/docs.d.ts +++ b/test/docs-json/docs.d.ts @@ -316,6 +316,14 @@ export interface JsonDocsProp { * ``` */ required: boolean; + /** + * `true` if the prop has a `get()`. `false` otherwise + */ + getter: boolean; + /** + * `true` if the prop has a `set()`. `false` otherwise + */ + setter: boolean; } export interface JsonDocsMethod { name: string; diff --git a/test/end-to-end/src/app-root/app-root.e2e.ts b/test/end-to-end/src/app-root/app-root.e2e.ts index ef7def6c..6c1fa789 100644 --- a/test/end-to-end/src/app-root/app-root.e2e.ts +++ b/test/end-to-end/src/app-root/app-root.e2e.ts @@ -22,7 +22,9 @@ describe('goto root url', () => { // select the "prop-cmp" element within the page (same as querySelector) // and once it's received, then return the element's "textContent" property const elm = await page.find('prop-cmp >>> div'); - expect(elm).toEqualText('Hello, my name is Rindo JS'); + expect(elm).toEqualText( + 'Hello, my name is Rindo JS. My full name being Mr Rindo JS. I like to wear life preservers.', + ); await page.compareScreenshot('navigate to homepage', { fullPage: false, @@ -34,11 +36,11 @@ describe('goto root url', () => { it('should navigate to the index.html page with custom url searchParams', async () => { // create a new puppeteer page const page = await newE2EPage({ - url: '/?first=Doc&last=Brown', + url: '/?first=Doc&last=Brown&clothes=lab coats', }); const elm = await page.find('prop-cmp >>> div'); - expect(elm).toEqualText('Hello, my name is Doc Brown'); + expect(elm).toEqualText('Hello, my name is Doc Brown. My full name being Mr Doc Brown. I like to wear lab coats.'); await page.compareScreenshot('navigate to homepage with querystrings'); }); diff --git a/test/end-to-end/src/app-root/app-root.tsx b/test/end-to-end/src/app-root/app-root.tsx index b46f40e1..1e437f86 100644 --- a/test/end-to-end/src/app-root/app-root.tsx +++ b/test/end-to-end/src/app-root/app-root.tsx @@ -32,11 +32,13 @@ export class AppRoot { @format something = '12'; @State() first: string; @State() last: MeString; + @State() clothes: string; componentWillLoad() { const url = new URL(window.location.href); this.first = url.searchParams.get('first') || 'Rindo'; this.last = url.searchParams.get('last') || 'JS'; + this.clothes = url.searchParams.get('clothes') || 'life preservers'; console.log('lodash', _.camelCase('LODASH')); console.log('lodash-es', _es.camelCase('LODASH-ES')); } @@ -53,7 +55,7 @@ export class AppRoot { render() { return ( - +
); diff --git a/test/end-to-end/src/components.d.ts b/test/end-to-end/src/components.d.ts index 5e57f643..3f33202c 100644 --- a/test/end-to-end/src/components.d.ts +++ b/test/end-to-end/src/components.d.ts @@ -111,7 +111,12 @@ export namespace Components { interface PrerenderCmp { } interface PropCmp { + "clothes": string; "first": string; + /** + * @readonly + */ + "fullName": string; "lastName": string; /** * Mode @@ -545,7 +550,12 @@ declare namespace LocalJSX { interface PrerenderCmp { } interface PropCmp { + "clothes"?: string; "first"?: string; + /** + * @readonly + */ + "fullName"?: string; "lastName"?: string; /** * Mode diff --git a/test/end-to-end/src/prop-cmp/prop-cmp.e2e.ts b/test/end-to-end/src/prop-cmp/prop-cmp.e2e.ts index 0ab2066c..6c03844a 100644 --- a/test/end-to-end/src/prop-cmp/prop-cmp.e2e.ts +++ b/test/end-to-end/src/prop-cmp/prop-cmp.e2e.ts @@ -23,6 +23,7 @@ describe('@Prop', () => { // let's set new property values on the component elm.first = 'Marty'; elm.lastName = 'McFly'; + elm.clothes = 'down filled jackets'; }); // we just made a change and now the async queue need to process it @@ -31,15 +32,41 @@ describe('@Prop', () => { // select the "prop-cmp" element within the page (same as querySelector) const elm = await page.find('prop-cmp >>> div'); - expect(elm).toEqualText('Hello, my name is Marty McFly'); + expect(elm).toEqualText( + 'Hello, my name is Marty McFly. My full name being Mr Marty McFly. I like to wear down filled jackets.', + ); }); it('should set props from attributes', async () => { await page.setContent(` - + `); const elm = await page.find('prop-cmp >>> div'); - expect(elm).toEqualText('Hello, my name is Marty McFly'); + expect(elm).toEqualText( + 'Hello, my name is Marty McFly. My full name being Mr Marty McFly. I like to wear down filled jackets.', + ); + }); + + it('should not set read-only props', async () => { + await page.setContent(` + + `); + + const elm = await page.find('prop-cmp >>> div'); + expect(elm).toEqualText( + 'Hello, my name is Marty McFly. My full name being Mr Marty McFly. I like to wear life preservers.', + ); + }); + + it('should not set read-only props or override conditional setters', async () => { + await page.setContent(` + + `); + + const elm = await page.find('prop-cmp >>> div'); + expect(elm).toEqualText( + 'Hello, my name is Marty McFly. My full name being Mr Marty McFly. I like to wear life preservers.', + ); }); }); diff --git a/test/end-to-end/src/prop-cmp/prop-cmp.tsx b/test/end-to-end/src/prop-cmp/prop-cmp.tsx index 4db9089b..61ff50f7 100644 --- a/test/end-to-end/src/prop-cmp/prop-cmp.tsx +++ b/test/end-to-end/src/prop-cmp/prop-cmp.tsx @@ -13,8 +13,20 @@ import { saveAs } from 'file-saver'; shadow: true, }) export class PropCmp { + private _clothes = 'life preservers'; @Prop() first: string; @Prop() lastName: string; + @Prop() + get fullName() { + return 'Mr ' + this.first + ' ' + this.lastName; + } + @Prop() + get clothes() { + return this._clothes; + } + set clothes(newVal: string) { + if (newVal === 'lab coats' || newVal === 'down filled jackets') this._clothes = newVal; + } saveAs() { saveAs('data', 'filename.txt'); @@ -24,7 +36,8 @@ export class PropCmp { return (
- Hello, my name is {this.first} {this.lastName} + Hello, my name is {this.first} {this.lastName}. My full name being {this.fullName}. I like to wear{' '} + {this.clothes}.
diff --git a/test/end-to-end/src/prop-cmp/readme.md b/test/end-to-end/src/prop-cmp/readme.md index 24dc4b43..245ae8e2 100644 --- a/test/end-to-end/src/prop-cmp/readme.md +++ b/test/end-to-end/src/prop-cmp/readme.md @@ -7,11 +7,13 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| ---------- | ----------- | ----------- | -------- | ----------- | -| `first` | `first` | | `string` | `undefined` | -| `lastName` | `last-name` | | `string` | `undefined` | -| `mode` | `mode` | Mode | `any` | `undefined` | +| Property | Attribute | Description | Type | Default | +| ---------- | ----------- | ----------- | -------- | ------------------- | +| `clothes` | `clothes` | | `string` | `'life preservers'` | +| `first` | `first` | | `string` | `undefined` | +| `fullName` | `full-name` | | `string` | `undefined` | +| `lastName` | `last-name` | | `string` | `undefined` | +| `mode` | `mode` | Mode | `any` | `undefined` | ## Dependencies From 855717c0814c3b1b4f1a4e60046e44564c0b6da0 Mon Sep 17 00:00:00 2001 From: NKDuy Date: Thu, 4 Sep 2025 13:20:00 +0700 Subject: [PATCH 2/9] fix: `patchChildSlotNodes` & `scopedSlotTextContentFix` not being applied --- src/compiler/config/validate-config.ts | 6 +- src/declarations/rindo-private.ts | 7 + src/runtime/bootstrap-custom-element.ts | 4 +- src/runtime/bootstrap-lazy.ts | 4 +- src/runtime/dom-extras.ts | 312 ++++++++++-------------- src/runtime/test/dom-extras.spec.tsx | 93 +++++++ src/runtime/vdom/vdom-render.ts | 22 +- 7 files changed, 246 insertions(+), 202 deletions(-) create mode 100644 src/runtime/test/dom-extras.spec.tsx diff --git a/src/compiler/config/validate-config.ts b/src/compiler/config/validate-config.ts index 1010cae9..23b1a63b 100644 --- a/src/compiler/config/validate-config.ts +++ b/src/compiler/config/validate-config.ts @@ -150,6 +150,8 @@ export const validateConfig = ( validatedConfig.extras.scriptDataOpts = !!validatedConfig.extras.scriptDataOpts; validatedConfig.extras.initializeNextTick = !!validatedConfig.extras.initializeNextTick; validatedConfig.extras.tagNameTransform = !!validatedConfig.extras.tagNameTransform; + // TODO(RINDO-1086): remove this option when it's the default behavior + validatedConfig.extras.experimentalScopedSlotChanges = !!validatedConfig.extras.experimentalScopedSlotChanges; // TODO(RINDO-914): remove when `experimentalSlotFixes` is the default behavior // If the user set `experimentalSlotFixes` and any individual slot fix flags to `false`, we need to log a warning @@ -160,6 +162,7 @@ export const validateConfig = ( 'slotChildNodesFix', 'cloneNodeFix', 'scopedSlotTextContentFix', + 'experimentalScopedSlotChanges', ]; const conflictingFlags = possibleFlags.filter((flag) => validatedConfig.extras[flag] === false); if (conflictingFlags.length > 0) { @@ -185,9 +188,6 @@ export const validateConfig = ( validatedConfig.extras.scopedSlotTextContentFix = !!validatedConfig.extras.scopedSlotTextContentFix; } - // TODO(RINDO-1086): remove this option when it's the default behavior - validatedConfig.extras.experimentalScopedSlotChanges = !!validatedConfig.extras.experimentalScopedSlotChanges; - setBooleanConfig( validatedConfig, 'sourceMap', diff --git a/src/declarations/rindo-private.ts b/src/declarations/rindo-private.ts index a852c4e9..90d38f1d 100644 --- a/src/declarations/rindo-private.ts +++ b/src/declarations/rindo-private.ts @@ -1455,6 +1455,13 @@ export interface RenderNode extends HostElement { * empty "" for shadow, "c" from scoped */ ['s-en']?: '' | /*shadow*/ 'c' /*scoped*/; + + /** + * On a `scoped: true` component + * with `experimentalSlotFixes` flag enabled, + * returns the internal `childNodes` of the scoped element + */ + readonly __childNodes?: NodeListOf; } export type LazyBundlesRuntimeData = LazyBundleRuntimeData[]; diff --git a/src/runtime/bootstrap-custom-element.ts b/src/runtime/bootstrap-custom-element.ts index 5e0fb57f..45cf147b 100644 --- a/src/runtime/bootstrap-custom-element.ts +++ b/src/runtime/bootstrap-custom-element.ts @@ -48,11 +48,11 @@ export const proxyCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMet if (BUILD.scoped && cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation) { // This check is intentionally not combined with the surrounding `experimentalSlotFixes` check // since, moving forward, we only want to patch the pseudo shadow DOM when the component is scoped - patchPseudoShadowDom(Cstr.prototype, cmpMeta); + patchPseudoShadowDom(Cstr.prototype); } } else { if (BUILD.slotChildNodesFix) { - patchChildSlotNodes(Cstr.prototype, cmpMeta); + patchChildSlotNodes(Cstr.prototype); } if (BUILD.cloneNodeFix) { patchCloneNode(Cstr.prototype); diff --git a/src/runtime/bootstrap-lazy.ts b/src/runtime/bootstrap-lazy.ts index d13b3278..3a504edf 100644 --- a/src/runtime/bootstrap-lazy.ts +++ b/src/runtime/bootstrap-lazy.ts @@ -171,11 +171,11 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d. // This check is intentionally not combined with the surrounding `experimentalSlotFixes` check // since, moving forward, we only want to patch the pseudo shadow DOM when the component is scoped if (BUILD.scoped && cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation) { - patchPseudoShadowDom(HostElement.prototype, cmpMeta); + patchPseudoShadowDom(HostElement.prototype); } } else { if (BUILD.slotChildNodesFix) { - patchChildSlotNodes(HostElement.prototype, cmpMeta); + patchChildSlotNodes(HostElement.prototype); } if (BUILD.cloneNodeFix) { patchCloneNode(HostElement.prototype); diff --git a/src/runtime/dom-extras.ts b/src/runtime/dom-extras.ts index e4da0146..95732584 100644 --- a/src/runtime/dom-extras.ts +++ b/src/runtime/dom-extras.ts @@ -1,15 +1,12 @@ import { BUILD } from '@app-data'; import { getHostRef, plt, supportsShadow } from '@platform'; -import { CMP_FLAGS, HOST_FLAGS, NODE_TYPES } from '@utils/constants'; +import { HOST_FLAGS } from '@utils/constants'; import type * as d from '../declarations'; import { PLATFORM_FLAGS } from './runtime-constants'; import { insertBefore, updateFallbackSlotVisibility } from './vdom/vdom-render'; -export const patchPseudoShadowDom = ( - hostElementPrototype: HTMLElement, - descriptorPrototype: d.ComponentRuntimeMeta, -) => { +export const patchPseudoShadowDom = (hostElementPrototype: HTMLElement) => { patchCloneNode(hostElementPrototype); patchSlotAppendChild(hostElementPrototype); patchSlotAppend(hostElementPrototype); @@ -18,7 +15,7 @@ export const patchPseudoShadowDom = ( patchSlotInsertAdjacentHTML(hostElementPrototype); patchSlotInsertAdjacentText(hostElementPrototype); patchTextContent(hostElementPrototype); - patchChildSlotNodes(hostElementPrototype, descriptorPrototype); + patchChildSlotNodes(hostElementPrototype); patchSlotRemoveChild(hostElementPrototype); }; @@ -49,10 +46,11 @@ export const patchCloneNode = (HostElementPrototype: HTMLElement) => { 's-rf', 's-scs', ]; + const childNodes = (this as any).__childNodes || this.childNodes; - for (; i < srcNode.childNodes.length; i++) { - slotted = (srcNode.childNodes[i] as any)['s-nr']; - nonRindoNode = rindoPrivates.every((privateField) => !(srcNode.childNodes[i] as any)[privateField]); + for (; i < childNodes.length; i++) { + slotted = (childNodes[i] as any)['s-nr']; + nonRindoNode = rindoPrivates.every((privateField) => !(childNodes[i] as any)[privateField]); if (slotted) { if (BUILD.appendChildSlotFix && (clonedNode as any).__appendChild) { (clonedNode as any).__appendChild(slotted.cloneNode(true)); @@ -61,7 +59,7 @@ export const patchCloneNode = (HostElementPrototype: HTMLElement) => { } } if (nonRindoNode) { - clonedNode.appendChild((srcNode.childNodes[i] as any).cloneNode(true)); + clonedNode.appendChild((childNodes[i] as any).cloneNode(true)); } } } @@ -81,13 +79,9 @@ export const patchSlotAppendChild = (HostElementPrototype: any) => { HostElementPrototype.__appendChild = HostElementPrototype.appendChild; HostElementPrototype.appendChild = function (this: d.RenderNode, newChild: d.RenderNode) { const slotName = (newChild['s-sn'] = getSlotName(newChild)); - const slotNode = getHostSlotNode(this.childNodes, slotName, this.tagName); + const slotNode = getHostSlotNode((this as any).__childNodes || this.childNodes, slotName, this.tagName); if (slotNode) { - const slotPlaceholder: d.RenderNode = document.createTextNode('') as any; - slotPlaceholder['s-nr'] = newChild; - (slotNode['s-cr'].parentNode as any).__appendChild(slotPlaceholder); - newChild['s-ol'] = slotPlaceholder; - newChild['s-sh'] = slotNode['s-hn']; + addSlotRelocateNode(newChild, slotNode); const slotChildNodes = getHostSlotChildNodes(slotNode, slotName); const appendAfter = slotChildNodes[slotChildNodes.length - 1]; @@ -111,22 +105,17 @@ export const patchSlotAppendChild = (HostElementPrototype: any) => { */ const patchSlotRemoveChild = (ElementPrototype: any) => { ElementPrototype.__removeChild = ElementPrototype.removeChild; + ElementPrototype.removeChild = function (this: d.RenderNode, toRemove: d.RenderNode) { if (toRemove && typeof toRemove['s-sn'] !== 'undefined') { - const slotNode = getHostSlotNode(this.childNodes, toRemove['s-sn'], this.tagName); - if (slotNode) { - // Get all slot content - const slotChildNodes = getHostSlotChildNodes(slotNode, toRemove['s-sn']); - // See if any of the slotted content matches the node to remove - const existingNode = slotChildNodes.find((n) => n === toRemove); - - if (existingNode) { - existingNode.remove(); - // Check if there is fallback content that should be displayed if that - // was the last node in the slot - updateFallbackSlotVisibility(this); - return; - } + const childNodes = (this as any).__childNodes || this.childNodes; + const slotNode = getHostSlotNode(childNodes, toRemove['s-sn'], this.tagName); + if (slotNode && toRemove.isConnected) { + toRemove.remove(); + // Check if there is fallback content that should be displayed if that + // was the last node in the slot + updateFallbackSlotVisibility(this); + return; } } return (this as any).__removeChild(toRemove); @@ -139,7 +128,7 @@ const patchSlotRemoveChild = (ElementPrototype: any) => { * @param HostElementPrototype the `Element` to be patched */ export const patchSlotPrepend = (HostElementPrototype: HTMLElement) => { - const originalPrepend = HostElementPrototype.prepend; + (HostElementPrototype as any).__prepend = HostElementPrototype.prepend; HostElementPrototype.prepend = function (this: d.HostElement, ...newChildren: (d.RenderNode | string)[]) { newChildren.forEach((newChild: d.RenderNode | string) => { @@ -147,14 +136,10 @@ export const patchSlotPrepend = (HostElementPrototype: HTMLElement) => { newChild = this.ownerDocument.createTextNode(newChild) as unknown as d.RenderNode; } const slotName = (newChild['s-sn'] = getSlotName(newChild)); - const slotNode = getHostSlotNode(this.childNodes, slotName, this.tagName); + const childNodes = (this as any).__childNodes || this.childNodes; + const slotNode = getHostSlotNode(childNodes, slotName, this.tagName); if (slotNode) { - const slotPlaceholder: d.RenderNode = document.createTextNode('') as any; - slotPlaceholder['s-nr'] = newChild; - (slotNode['s-cr'].parentNode as any).__appendChild(slotPlaceholder); - newChild['s-ol'] = slotPlaceholder; - newChild['s-sh'] = slotNode['s-hn']; - + addSlotRelocateNode(newChild, slotNode, true); const slotChildNodes = getHostSlotChildNodes(slotNode, slotName); const appendAfter = slotChildNodes[0]; return insertBefore(appendAfter.parentNode, newChild, appendAfter.nextSibling); @@ -164,7 +149,7 @@ export const patchSlotPrepend = (HostElementPrototype: HTMLElement) => { newChild.hidden = true; } - return originalPrepend.call(this, newChild); + return (HostElementPrototype as any).__prepend(newChild); }); }; }; @@ -176,6 +161,7 @@ export const patchSlotPrepend = (HostElementPrototype: HTMLElement) => { * @param HostElementPrototype the `Element` to be patched */ export const patchSlotAppend = (HostElementPrototype: HTMLElement) => { + (HostElementPrototype as any).__append = HostElementPrototype.append; HostElementPrototype.append = function (this: d.HostElement, ...newChildren: (d.RenderNode | string)[]) { newChildren.forEach((newChild: d.RenderNode | string) => { if (typeof newChild === 'string') { @@ -263,172 +249,124 @@ export const patchSlotInsertAdjacentElement = (HostElementPrototype: HTMLElement * @param hostElementPrototype the `Element` to be patched */ export const patchTextContent = (hostElementPrototype: HTMLElement): void => { - const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent'); - - Object.defineProperty(hostElementPrototype, '__textContent', descriptor); - - if (BUILD.experimentalScopedSlotChanges) { - // Patch `textContent` to mimic shadow root behavior - Object.defineProperty(hostElementPrototype, 'textContent', { - // To mimic shadow root behavior, we need to return the text content of all - // nodes in a slot reference node - get(): string | null { - const slotRefNodes = getAllChildSlotNodes(this.childNodes); - - const textContent = slotRefNodes - .map((node) => { - const text = []; - - // Need to get the text content of all nodes in the slot reference node - let slotContent = node.nextSibling as d.RenderNode | null; - while (slotContent && slotContent['s-sn'] === node['s-sn']) { - if (slotContent.nodeType === NODE_TYPES.TEXT_NODE || slotContent.nodeType === NODE_TYPES.ELEMENT_NODE) { - text.push(slotContent.textContent?.trim() ?? ''); - } - slotContent = slotContent.nextSibling as d.RenderNode | null; - } - - return text.filter((ref) => ref !== '').join(' '); - }) - .filter((text) => text !== '') - .join(' '); - - // Pad the string to return - return ' ' + textContent + ' '; - }, - - // To mimic shadow root behavior, we need to overwrite all nodes in a slot - // reference node. If a default slot reference node exists, the text content will be - // placed there. Otherwise, the new text node will be hidden - set(value: string | null) { - const slotRefNodes = getAllChildSlotNodes(this.childNodes); - - slotRefNodes.forEach((node) => { - // Remove the existing content of the slot - let slotContent = node.nextSibling as d.RenderNode | null; - while (slotContent && slotContent['s-sn'] === node['s-sn']) { - const tmp = slotContent; - slotContent = slotContent.nextSibling as d.RenderNode | null; - tmp.remove(); - } + let descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent'); - // If this is a default slot, add the text node in the slot location. - // Otherwise, destroy the slot reference node - if (node['s-sn'] === '') { - const textNode = this.ownerDocument.createTextNode(value); - textNode['s-sn'] = ''; - insertBefore(node.parentElement, textNode, node.nextSibling); - } else { - node.remove(); - } - }); - }, - }); - } else { - Object.defineProperty(hostElementPrototype, 'textContent', { - get(): string | null { - // get the 'default slot', which would be the first slot in a shadow tree (if we were using one), whose name is - // the empty string - const slotNode = getHostSlotNode(this.childNodes, '', this.tagName); - // when a slot node is found, the textContent _may_ be found in the next sibling (text) node, depending on how - // nodes were reordered during the vdom render. first try to get the text content from the sibling. - if (slotNode?.nextSibling?.nodeType === NODE_TYPES.TEXT_NODE) { - return slotNode.nextSibling.textContent; - } else if (slotNode) { - return slotNode.textContent; - } else { - // fallback to the original implementation - return this.__textContent; - } - }, - - set(value: string | null) { - // get the 'default slot', which would be the first slot in a shadow tree (if we were using one), whose name is - // the empty string - const slotNode = getHostSlotNode(this.childNodes, '', this.tagName); - // when a slot node is found, the textContent _may_ need to be placed in the next sibling (text) node, - // depending on how nodes were reordered during the vdom render. first try to set the text content on the - // sibling. - if (slotNode?.nextSibling?.nodeType === NODE_TYPES.TEXT_NODE) { - slotNode.nextSibling.textContent = value; - } else if (slotNode) { - slotNode.textContent = value; - } else { - // we couldn't find a slot, but that doesn't mean that there isn't one. if this check ran before the DOM - // loaded, we could have missed it. check for a content reference element on the scoped component and insert - // it there - this.__textContent = value; - const contentRefElm = this['s-cr']; - if (contentRefElm) { - insertBefore(this, contentRefElm, this.firstChild); - } - } - }, - }); + if (!descriptor) { + // for mock-doc + descriptor = Object.getOwnPropertyDescriptor(hostElementPrototype, 'textContent'); } + if (descriptor) Object.defineProperty(hostElementPrototype, '__textContent', descriptor); + + Object.defineProperty(hostElementPrototype, 'textContent', { + get: function () { + let text = ''; + const childNodes = this.__childNodes ? this.childNodes : getSlottedChildNodes(this.childNodes); + childNodes.forEach((node: d.RenderNode) => (text += node.textContent || '')); + return text; + }, + set: function (value) { + const childNodes = this.__childNodes ? this.childNodes : getSlottedChildNodes(this.childNodes); + childNodes.forEach((node: d.RenderNode) => { + if (node['s-ol']) node['s-ol'].remove(); + node.remove(); + }); + this.insertAdjacentHTML('beforeend', value); + }, + }); }; -export const patchChildSlotNodes = (elm: HTMLElement, cmpMeta: d.ComponentRuntimeMeta) => { +export const patchChildSlotNodes = (elm: HTMLElement) => { class FakeNodeList extends Array { item(n: number) { return this[n]; } } - // TODO(RINDO-854): Remove code related to legacy shadowDomShim field - if (cmpMeta.$flags$ & CMP_FLAGS.needsShadowDomShim) { - const childNodesFn = (elm as any).__lookupGetter__('childNodes'); - - Object.defineProperty(elm, 'children', { - get() { - return this.childNodes.map((n: any) => n.nodeType === 1); - }, - }); - - Object.defineProperty(elm, 'childElementCount', { - get() { - return elm.children.length; - }, - }); - Object.defineProperty(elm, 'childNodes', { - get() { - const childNodes = childNodesFn.call(this) as NodeListOf; - if ( - (plt.$flags$ & PLATFORM_FLAGS.isTmpDisconnected) === 0 && - getHostRef(this).$flags$ & HOST_FLAGS.hasRendered - ) { - const result = new FakeNodeList(); - for (let i = 0; i < childNodes.length; i++) { - const slot = childNodes[i]['s-nr']; - if (slot) { - result.push(slot); - } - } - return result; - } - return FakeNodeList.from(childNodes); - }, - }); + let childNodesFn = Object.getOwnPropertyDescriptor(Node.prototype, 'childNodes'); + if (!childNodesFn) { + // for mock-doc + childNodesFn = Object.getOwnPropertyDescriptor(elm, 'childNodes'); } + + if (childNodesFn) Object.defineProperty(elm, '__childNodes', childNodesFn); + + Object.defineProperty(elm, 'children', { + get() { + return this.childNodes.filter((n: any) => n.nodeType === 1); + }, + }); + + Object.defineProperty(elm, 'childElementCount', { + get() { + return this.children.length; + }, + }); + + if (!childNodesFn) return; + + Object.defineProperty(elm, 'childNodes', { + get() { + if ( + !plt.$flags$ || + !getHostRef(this)?.$flags$ || + ((plt.$flags$ & PLATFORM_FLAGS.isTmpDisconnected) === 0 && getHostRef(this)?.$flags$ & HOST_FLAGS.hasRendered) + ) { + const result = new FakeNodeList(); + const nodes = getSlottedChildNodes(this.__childNodes); + result.push(...nodes); + return result; + } + return FakeNodeList.from(this.__childNodes); + }, + }); }; +/// UTILS /// + /** - * Recursively finds all slot reference nodes ('s-sr') in a series of child nodes. - * - * @param childNodes The set of child nodes to search for slot reference nodes. - * @returns An array of slot reference nodes. + * Creates an empty text node to act as a forwarding address to a slotted node: + * 1) When non-shadow components re-render, they need a place to temporarily put 'lightDOM' elements. + * 2) Patched dom methods and accessors use this node to calculate what 'lightDOM' nodes are in the host. + * @param newChild a node that's going to be added to the component + * @param slotNode the slot node that the node will be added to + * @param prepend move the slotted location node to the beginning of the host + * (the order of the slot location nodes determines the order of the slotted nodes in our patched accessors) */ -const getAllChildSlotNodes = (childNodes: NodeListOf): d.RenderNode[] => { - const slotRefNodes = []; +export const addSlotRelocateNode = (newChild: d.RenderNode, slotNode: d.RenderNode, prepend?: boolean) => { + let slottedNodeLocation: d.RenderNode; + // does newChild already have a slot location node? + if (newChild['s-ol'] && newChild['s-ol'].isConnected) { + slottedNodeLocation = newChild['s-ol']; + } else { + slottedNodeLocation = document.createTextNode('') as any; + slottedNodeLocation['s-nr'] = newChild; + } + + const parent = slotNode['s-cr'].parentNode as any; + const appendMethod = prepend ? parent.__prepend : parent.__appendChild; + + newChild['s-ol'] = slottedNodeLocation; + newChild['s-sh'] = slotNode['s-hn']; + + appendMethod.call(parent, slottedNodeLocation); +}; - for (const childNode of Array.from(childNodes) as d.RenderNode[]) { - if (childNode['s-sr']) { - slotRefNodes.push(childNode); +/** + * Get's the child nodes of a component that are actually slotted. + * This is only required until all patches are unified + * either under 'experimentalSlotFixes' or on by default + * @param childNodes all 'internal' child nodes of the component + * @returns An array of slotted reference nodes. + */ +const getSlottedChildNodes = (childNodes: NodeListOf) => { + const result = []; + for (let i = 0; i < childNodes.length; i++) { + const slottedNode = childNodes[i]['s-nr']; + if (slottedNode && slottedNode.isConnected) { + result.push(slottedNode); } - slotRefNodes.push(...getAllChildSlotNodes(childNode.childNodes)); } - - return slotRefNodes; + return result; }; const getSlotName = (node: d.RenderNode) => diff --git a/src/runtime/test/dom-extras.spec.tsx b/src/runtime/test/dom-extras.spec.tsx new file mode 100644 index 00000000..5a96d880 --- /dev/null +++ b/src/runtime/test/dom-extras.spec.tsx @@ -0,0 +1,93 @@ +import { Component, h, Host } from '@rindo/core'; +import { newSpecPage, SpecPage } from '@rindo/core/testing'; + +import { patchPseudoShadowDom } from '../../runtime/dom-extras'; + +describe('dom-extras - patches for non-shadow dom methods and accessors', () => { + let specPage: SpecPage; + + const nodeOrEleContent = (node: Node | Element) => { + return (node as Element)?.outerHTML || node?.nodeValue.trim(); + }; + + beforeEach(async () => { + @Component({ + tag: 'cmp-a', + scoped: true, + }) + class CmpA { + render() { + return ( + + 'Shadow' first text node +
+
+ Second slot fallback text +
+
+ Default slot fallback text +
+
+ 'Shadow' last text node +
+ ); + } + } + + specPage = await newSpecPage({ + components: [CmpA], + html: ` + + Some default slot, slotted text + a default slot, slotted element +
+ a second slot, slotted element + nested element in the second slot +
+ `, + hydrateClientSide: true, + }); + + patchPseudoShadowDom(specPage.root); + }); + + it('patches `childNodes` to return only nodes that have been slotted', async () => { + const childNodes = specPage.root.childNodes; + + expect(nodeOrEleContent(childNodes[0])).toBe(`Some default slot, slotted text`); + expect(nodeOrEleContent(childNodes[1])).toBe(`a default slot, slotted element`); + expect(nodeOrEleContent(childNodes[2])).toBe(``); + expect(nodeOrEleContent(childNodes[3])).toBe( + `
a second slot, slotted element nested element in the second slot
`, + ); + + const innerChildNodes = specPage.root.__childNodes; + + expect(nodeOrEleContent(innerChildNodes[0])).toBe(``); + expect(nodeOrEleContent(innerChildNodes[1])).toBe(``); + expect(nodeOrEleContent(innerChildNodes[2])).toBe(``); + expect(nodeOrEleContent(innerChildNodes[3])).toBe(``); + expect(nodeOrEleContent(innerChildNodes[4])).toBe(``); + expect(nodeOrEleContent(innerChildNodes[5])).toBe(`'Shadow' first text node`); + }); + + it('patches `children` to return only elements that have been slotted', async () => { + const children = specPage.root.children; + + expect(nodeOrEleContent(children[0])).toBe(`a default slot, slotted element`); + expect(nodeOrEleContent(children[1])).toBe( + `
a second slot, slotted element nested element in the second slot
`, + ); + expect(nodeOrEleContent(children[2])).toBe(undefined); + }); + + it('patches `childElementCount` to only count elements that have been slotted', async () => { + expect(specPage.root.childElementCount).toBe(2); + }); + + it('patches `textContent` to only return slotted node text', async () => { + expect(specPage.root.textContent.replace(/\s+/g, ' ').trim()).toBe( + `Some default slot, slotted text a default slot, slotted element a second slot, slotted element nested element in the second slot`, + ); + }); +}); diff --git a/src/runtime/vdom/vdom-render.ts b/src/runtime/vdom/vdom-render.ts index 5e241a23..dab91d84 100644 --- a/src/runtime/vdom/vdom-render.ts +++ b/src/runtime/vdom/vdom-render.ts @@ -195,8 +195,12 @@ const relocateToHostRoot = (parentElm: Element) => { const host = parentElm.closest(hostTagName.toLowerCase()); if (host != null) { - const contentRefNode = (Array.from(host.childNodes) as d.RenderNode[]).find((ref) => ref['s-cr']); - const childNodeArray = Array.from(parentElm.childNodes) as d.RenderNode[]; + const contentRefNode = (Array.from((host as d.RenderNode).__childNodes || host.childNodes) as d.RenderNode[]).find( + (ref) => ref['s-cr'], + ); + const childNodeArray = Array.from( + (parentElm as d.RenderNode).__childNodes || parentElm.childNodes, + ) as d.RenderNode[]; // If we have a content ref, we need to invert the order of the nodes we're relocating // to preserve the correct order of elements in the DOM on future relocations @@ -219,7 +223,7 @@ const relocateToHostRoot = (parentElm: Element) => { const putBackInOriginalLocation = (parentElm: d.RenderNode, recursive: boolean) => { plt.$flags$ |= PLATFORM_FLAGS.isTmpDisconnected; - const oldSlotChildNodes: ChildNode[] = Array.from(parentElm.childNodes); + const oldSlotChildNodes: ChildNode[] = Array.from(parentElm.__childNodes || parentElm.childNodes); if (parentElm['s-sr'] && BUILD.experimentalSlotFixes) { let node = parentElm; @@ -741,7 +745,7 @@ export const patch = (oldVNode: d.VNode, newVNode: d.VNode, isInitialRender = fa * @param elm the element of interest */ export const updateFallbackSlotVisibility = (elm: d.RenderNode) => { - const childNodes: d.RenderNode[] = elm.childNodes as any; + const childNodes: d.RenderNode[] = elm.__childNodes || (elm.childNodes as any); for (const childNode of childNodes) { if (childNode.nodeType === NODE_TYPE.ElementNode) { @@ -812,13 +816,14 @@ const markSlotContentForRelocation = (elm: d.RenderNode) => { let hostContentNodes: NodeList; let j; - for (const childNode of elm.childNodes as unknown as d.RenderNode[]) { + const children = elm.__childNodes || elm.childNodes; + for (const childNode of children as unknown as d.RenderNode[]) { // we need to find child nodes which are slot references so we can then try // to match them up with nodes that need to be relocated if (childNode['s-sr'] && (node = childNode['s-cr']) && node.parentNode) { // first get the content reference comment node ('s-cr'), then we get // its parent, which is where all the host content is now - hostContentNodes = node.parentNode.childNodes; + hostContentNodes = (node.parentNode as d.RenderNode).__childNodes || node.parentNode.childNodes; const slotName = childNode['s-sn']; // iterate through all the nodes under the location where the host was @@ -992,7 +997,7 @@ const updateElementScopeIds = (element: d.RenderNode, parent: d.RenderNode, iter * So, we need to notify the child nodes to update their new scope ids since * the DOM structure is changed. */ - for (const childNode of Array.from(element.childNodes)) { + for (const childNode of Array.from(element.__childNodes || element.childNodes)) { updateElementScopeIds(childNode as d.RenderNode, element, true); } } @@ -1235,7 +1240,8 @@ render() { // Only an issue if there were no "slots" rendered. Otherwise, nodes are hidden correctly. // This _only_ happens for `scoped` components! if (BUILD.experimentalScopedSlotChanges && cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation) { - for (const childNode of rootVnode.$elm$.childNodes) { + const children = rootVnode.$elm$.__childNodes || rootVnode.$elm$.childNodes; + for (const childNode of children) { if (childNode['s-hn'] !== hostTagName && !childNode['s-sh']) { // Store the initial value of `hidden` so we can reset it later when // moving nodes around. From e68ff0831d7b74cedfe06ddb95bbaff28cb4ee62 Mon Sep 17 00:00:00 2001 From: NKDuy Date: Thu, 4 Sep 2025 13:35:38 +0700 Subject: [PATCH 3/9] fix: stop `experimentalScopedSlotChanges` warning msg on startup --- src/compiler/config/validate-config.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/compiler/config/validate-config.ts b/src/compiler/config/validate-config.ts index 23b1a63b..80e9f915 100644 --- a/src/compiler/config/validate-config.ts +++ b/src/compiler/config/validate-config.ts @@ -150,8 +150,6 @@ export const validateConfig = ( validatedConfig.extras.scriptDataOpts = !!validatedConfig.extras.scriptDataOpts; validatedConfig.extras.initializeNextTick = !!validatedConfig.extras.initializeNextTick; validatedConfig.extras.tagNameTransform = !!validatedConfig.extras.tagNameTransform; - // TODO(RINDO-1086): remove this option when it's the default behavior - validatedConfig.extras.experimentalScopedSlotChanges = !!validatedConfig.extras.experimentalScopedSlotChanges; // TODO(RINDO-914): remove when `experimentalSlotFixes` is the default behavior // If the user set `experimentalSlotFixes` and any individual slot fix flags to `false`, we need to log a warning @@ -181,11 +179,14 @@ export const validateConfig = ( validatedConfig.extras.cloneNodeFix = true; validatedConfig.extras.slotChildNodesFix = true; validatedConfig.extras.scopedSlotTextContentFix = true; + validatedConfig.extras.experimentalScopedSlotChanges = true; } else { validatedConfig.extras.appendChildSlotFix = !!validatedConfig.extras.appendChildSlotFix; validatedConfig.extras.cloneNodeFix = !!validatedConfig.extras.cloneNodeFix; validatedConfig.extras.slotChildNodesFix = !!validatedConfig.extras.slotChildNodesFix; validatedConfig.extras.scopedSlotTextContentFix = !!validatedConfig.extras.scopedSlotTextContentFix; + // TODO(RINDO-1086): remove this option when it's the default behavior + validatedConfig.extras.experimentalScopedSlotChanges = !!validatedConfig.extras.experimentalScopedSlotChanges; } setBooleanConfig( From 8ca7e1d14923893ed83338b25f4e614859de0f6a Mon Sep 17 00:00:00 2001 From: NKDuy Date: Thu, 4 Sep 2025 13:55:09 +0700 Subject: [PATCH 4/9] fix(runtime): ensure `Node` is defined --- src/runtime/dom-extras.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/dom-extras.ts b/src/runtime/dom-extras.ts index 95732584..222c4311 100644 --- a/src/runtime/dom-extras.ts +++ b/src/runtime/dom-extras.ts @@ -249,7 +249,7 @@ export const patchSlotInsertAdjacentElement = (HostElementPrototype: HTMLElement * @param hostElementPrototype the `Element` to be patched */ export const patchTextContent = (hostElementPrototype: HTMLElement): void => { - let descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent'); + let descriptor = globalThis.Node && Object.getOwnPropertyDescriptor(Node.prototype, 'textContent'); if (!descriptor) { // for mock-doc @@ -282,7 +282,7 @@ export const patchChildSlotNodes = (elm: HTMLElement) => { } } - let childNodesFn = Object.getOwnPropertyDescriptor(Node.prototype, 'childNodes'); + let childNodesFn = globalThis.Node && Object.getOwnPropertyDescriptor(Node.prototype, 'childNodes'); if (!childNodesFn) { // for mock-doc childNodesFn = Object.getOwnPropertyDescriptor(elm, 'childNodes'); From b98e0bbb9adb51bd5395f7c4064773a33ed0d07a Mon Sep 17 00:00:00 2001 From: NKDuy Date: Thu, 4 Sep 2025 14:10:06 +0700 Subject: [PATCH 5/9] fix: change `hasHostListenerAttached` from var to protoype property --- src/runtime/bootstrap-custom-element.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/runtime/bootstrap-custom-element.ts b/src/runtime/bootstrap-custom-element.ts index 45cf147b..24f73683 100644 --- a/src/runtime/bootstrap-custom-element.ts +++ b/src/runtime/bootstrap-custom-element.ts @@ -67,16 +67,16 @@ export const proxyCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMet const originalConnectedCallback = Cstr.prototype.connectedCallback; const originalDisconnectedCallback = Cstr.prototype.disconnectedCallback; - let hasHostListenerAttached = false; Object.assign(Cstr.prototype, { + __hasHostListenerAttached: false, __registerHost() { registerHost(this, cmpMeta); }, connectedCallback() { - if (!hasHostListenerAttached) { + if (!this.__hasHostListenerAttached) { const hostRef = getHostRef(this); addHostEventListeners(this, hostRef, cmpMeta.$listeners$, false); - hasHostListenerAttached = true; + this.__hasHostListenerAttached = true; } connectedCallback(this); From 9403593fd9431cfc3239f39ab098eb1b737f8a86 Mon Sep 17 00:00:00 2001 From: NKDuy Date: Thu, 4 Sep 2025 21:10:53 +0700 Subject: [PATCH 6/9] fix: rewrite SSR client-side hydration --- src/declarations/rindo-private.ts | 6 + src/mock-doc/serialize-node.ts | 31 +- src/runtime/client-hydrate.ts | 478 ++++++++++++---- src/runtime/connected-callback.ts | 7 +- src/runtime/dom-extras.ts | 42 +- src/runtime/runtime-constants.ts | 1 + .../test/hydrate-no-encapsulation.spec.tsx | 12 - src/runtime/test/hydrate-scoped.spec.tsx | 7 +- .../test/hydrate-shadow-child.spec.tsx | 29 +- .../test/hydrate-shadow-in-shadow.spec.tsx | 7 +- .../test/hydrate-shadow-parent.spec.tsx | 18 +- src/runtime/test/hydrate-shadow.spec.tsx | 4 +- .../test/hydrate-slot-fallback.spec.tsx | 435 ++++++++++++++ .../hydrate-slotted-content-order.spec.tsx | 541 ++++++++++++++++++ src/runtime/vdom/set-accessor.ts | 8 +- src/runtime/vdom/vdom-annotations.ts | 13 +- src/runtime/vdom/vdom-render.ts | 2 +- test/end-to-end/rindo.config.ts | 3 + test/end-to-end/src/components.d.ts | 65 +++ .../src/declarative-shadow-dom/test.e2e.ts | 2 +- .../non-shadow-forwarded-slot.tsx | 32 ++ .../scoped-hydration/non-shadow-wrapper.tsx | 25 + .../src/scoped-hydration/non-shadow.tsx | 25 + .../end-to-end/src/scoped-hydration/readme.md | 8 + .../scoped-hydration/scoped-hydration.e2e.ts | 109 ++++ .../src/scoped-hydration/shadow-wrapper.tsx | 25 + .../src/scoped-hydration/shadow.tsx | 27 + 27 files changed, 1789 insertions(+), 173 deletions(-) create mode 100644 src/runtime/test/hydrate-slot-fallback.spec.tsx create mode 100644 src/runtime/test/hydrate-slotted-content-order.spec.tsx create mode 100644 test/end-to-end/src/scoped-hydration/non-shadow-forwarded-slot.tsx create mode 100644 test/end-to-end/src/scoped-hydration/non-shadow-wrapper.tsx create mode 100644 test/end-to-end/src/scoped-hydration/non-shadow.tsx create mode 100644 test/end-to-end/src/scoped-hydration/readme.md create mode 100644 test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts create mode 100644 test/end-to-end/src/scoped-hydration/shadow-wrapper.tsx create mode 100644 test/end-to-end/src/scoped-hydration/shadow.tsx diff --git a/src/declarations/rindo-private.ts b/src/declarations/rindo-private.ts index 90d38f1d..4ec58d1a 100644 --- a/src/declarations/rindo-private.ts +++ b/src/declarations/rindo-private.ts @@ -1435,6 +1435,12 @@ export interface RenderNode extends HostElement { */ ['s-nr']?: RenderNode; + /** + * Original Order: + * During SSR; a number representing the order of a slotted node + */ + ['s-oo']?: number; + /** * Scope Id */ diff --git a/src/mock-doc/serialize-node.ts b/src/mock-doc/serialize-node.ts index 1c7ed3a6..b356ce16 100644 --- a/src/mock-doc/serialize-node.ts +++ b/src/mock-doc/serialize-node.ts @@ -67,7 +67,7 @@ export function serializeNodeToHtml(elm: Node | MockNode, serializationOptions: ? Array.from((elm as MockDocument).body.childNodes) : opts.outerHtml ? [elm] - : Array.from(elm.childNodes as NodeList); + : Array.from(getChildNodes(elm)); for (let i = 0, ii = children.length; i < ii; i++) { const child = children[i]; @@ -130,7 +130,10 @@ function* streamToHtml( * ToDo: the shadow root class is `#document-fragment` * and has no mode attribute. We should consider adding a mode attribute. */ - if (tag === 'template') { + if ( + tag === 'template' && + (!(node as Element).getAttribute || !(node as Element).getAttribute('shadowrootmode')) + ) { const mode = ` shadowrootmode="open"`; yield mode; output.currentLineWidth += mode.length; @@ -242,12 +245,13 @@ function* streamToHtml( yield* streamToHtml(shadowRoot, opts, output); output.indent = output.indent - (opts.indentSpaces ?? 0); + const childNodes = getChildNodes(node); if ( opts.newLines && - (node.childNodes.length === 0 || - (node.childNodes.length === 1 && - node.childNodes[0].nodeType === NODE_TYPES.TEXT_NODE && - node.childNodes[0].nodeValue?.trim() === '')) + (childNodes.length === 0 || + (childNodes.length === 1 && + childNodes[0].nodeType === NODE_TYPES.TEXT_NODE && + childNodes[0].nodeValue?.trim() === '')) ) { yield '\n'; output.currentLineWidth = 0; @@ -262,7 +266,9 @@ function* streamToHtml( if (opts.excludeTagContent == null || opts.excludeTagContent.includes(tagName) === false) { const tag = tagName === shadowRootTag ? 'template' : tagName; const childNodes = - tagName === 'template' ? ((node as any as HTMLTemplateElement).content.childNodes as any) : node.childNodes; + tagName === 'template' + ? ((node as any as HTMLTemplateElement).content.childNodes as any) + : getChildNodes(node); const childNodeLength = childNodes.length; if (childNodeLength > 0) { @@ -525,6 +531,17 @@ function isWithinWhitespaceSensitive(node: Node | MockNode) { return false; } +/** + * Normalizes the `childNodes` of a node due to if `experimentalSlotFixes` is enabled, ` + * childNodes` will only return 'slotted' / lightDOM nodes + * + * @param node to return `childNodes` from + * @returns a node list of child nodes + */ +function getChildNodes(node: Node | MockNode) { + return ((node as any).__childNodes || node.childNodes) as NodeList; +} + // TODO(RINDO-1299): Audit this list, remove unsupported/deprecated elements /*@__PURE__*/ export const NON_ESCAPABLE_CONTENT = new Set([ 'STYLE', diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index cb024bad..6c087b80 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -1,9 +1,11 @@ import { BUILD } from '@app-data'; -import { doc, plt, supportsShadow } from '@platform'; +import { doc, plt } from '@platform'; import type * as d from '../declarations'; +import { addSlotRelocateNode } from './dom-extras'; import { createTime } from './profile'; import { + COMMENT_NODE_ID, CONTENT_REF_ID, HYDRATE_CHILD_ID, HYDRATE_ID, @@ -15,14 +17,16 @@ import { import { newVNode } from './vdom/h'; /** - * Entrypoint of the client-side hydration process. Facilitates calls to hydrate the - * document and all its nodes. + * Takes an SSR rendered document, as annotated by 'vdom-annotations.ts' and: * - * This process will also reconstruct the shadow root and slot DOM nodes for components using shadow DOM. + * 1) Recreate an accurate VDOM which is fed to 'vdom-render.ts'. A failure to do so can cause hydration errors; extra renders, duplicated nodes + * 2) Add shadowDOM trees to their respective #document-fragment + * 3) Move forwarded, slotted nodes out of shadowDOMs + * 4) Add meta nodes to non-shadow DOMs and their 'slotted' nodes * * @param hostElm The element to hydrate. * @param tagName The element's tag name. - * @param hostId The host ID assigned to the element by the server. + * @param hostId The host ID assigned to the element by the server. e.g. `s-id="1"` * @param hostRef The host reference for the element. */ export const initializeClientHydrate = ( @@ -33,68 +37,178 @@ export const initializeClientHydrate = ( ) => { const endHydrate = createTime('hydrateClient', tagName); const shadowRoot = hostElm.shadowRoot; + // children placed by SSR within this component but don't necessarily belong to it. + // We need to keep tabs on them so we can move them to the right place later const childRenderNodes: RenderNodeData[] = []; + // nodes representing a `` element const slotNodes: RenderNodeData[] = []; + // nodes that have been slotted from outside the component + const slottedNodes: SlottedNodes[] = []; + // nodes that make up this component's shadowDOM const shadowRootNodes: d.RenderNode[] = BUILD.shadowDom && shadowRoot ? [] : null; - const vnode: d.VNode = (hostRef.$vnode$ = newVNode(tagName, null)); + // The root VNode for this component + const vnode: d.VNode = newVNode(tagName, null); + vnode.$elm$ = hostElm; if (!plt.$orgLocNodes$) { + // This is the first pass over of this whole document; + // does a scrape to construct a 'bare-bones' tree of what elements we have and where content has been moved from initializeDocumentHydrate(doc.body, (plt.$orgLocNodes$ = new Map())); } hostElm[HYDRATE_ID] = hostId; hostElm.removeAttribute(HYDRATE_ID); - clientHydrate(vnode, childRenderNodes, slotNodes, shadowRootNodes, hostElm, hostElm, hostId); - - childRenderNodes.map((c) => { - const orgLocationId = c.$hostId$ + '.' + c.$nodeId$; + hostRef.$vnode$ = clientHydrate( + vnode, + childRenderNodes, + slotNodes, + shadowRootNodes, + hostElm, + hostElm, + hostId, + slottedNodes, + ); + + let crIndex = 0; + const crLength = childRenderNodes.length; + let childRenderNode: RenderNodeData; + + // Steps through the child nodes we found. + // If moved from an original location (by nature of being rendered in SSR markup) we might be able to move it back there now, + // so slotted nodes don't get added to internal shadowDOMs + for (crIndex; crIndex < crLength; crIndex++) { + childRenderNode = childRenderNodes[crIndex]; + const orgLocationId = childRenderNode.$hostId$ + '.' + childRenderNode.$nodeId$; + // The original location of this node const orgLocationNode = plt.$orgLocNodes$.get(orgLocationId); - const node = c.$elm$ as d.RenderNode; - - // Put the node back in its original location since the native Shadow DOM - // can handle rendering it its correct location now - if (orgLocationNode && supportsShadow && orgLocationNode['s-en'] === '') { - orgLocationNode.parentNode.insertBefore(node, orgLocationNode.nextSibling); - } + const node = childRenderNode.$elm$ as d.RenderNode; if (!shadowRoot) { - node['s-hn'] = tagName; + node['s-hn'] = tagName.toUpperCase(); - if (orgLocationNode) { - node['s-ol'] = orgLocationNode; - node['s-ol']['s-nr'] = node; + if (childRenderNode.$tag$ === 'slot') { + // If this is a virtual 'slot', add it's Content-position Reference now. + // If we don't, `vdom-render.ts` will try to add nodes to it (and because it may be a comment node, it will error) + node['s-cr'] = hostElm['s-cr']; } } + if (orgLocationNode && orgLocationNode.isConnected) { + if (shadowRoot && orgLocationNode['s-en'] === '') { + // if this node is within a shadowDOM, with an original location home + // we're safe to move it now + orgLocationNode.parentNode.insertBefore(node, orgLocationNode.nextSibling); + } + // Remove original location / slot reference comment now regardless: + // 1) Stops SSR frameworks complaining about mismatches + // 2) is un-required for non-shadow, slotted nodes as we'll add all the meta nodes we need when we deal with *all* slotted nodes ↓↓↓ + orgLocationNode.parentNode.removeChild(orgLocationNode); + + if (!shadowRoot) { + // Add the Original Order of this node. + // We'll use it later to make sure slotted nodes get added in the correct order + node['s-oo'] = parseInt(childRenderNode.$nodeId$); + } + } + // Remove the original location from the map plt.$orgLocNodes$.delete(orgLocationId); - }); + } + + const hosts: d.HostElement[] = []; + let snIndex = 0; + const snLen = slottedNodes.length; + let slotGroup: SlottedNodes; + let snGroupIdx: number; + let snGroupLen: number; + let slottedItem: SlottedNodes[0]; + + // Loops through all the slotted nodes we found while stepping through this component + for (snIndex; snIndex < snLen; snIndex++) { + slotGroup = slottedNodes[snIndex]; + + if (!slotGroup || !slotGroup.length) continue; + + snGroupLen = slotGroup.length; + snGroupIdx = 0; + + for (snGroupIdx; snGroupIdx < snGroupLen; snGroupIdx++) { + slottedItem = slotGroup[snGroupIdx]; + + if (!hosts[slottedItem.hostId as any]) { + // Cache this host for other grouped slotted nodes + hosts[slottedItem.hostId as any] = plt.$orgLocNodes$.get(slottedItem.hostId); + } + // This *shouldn't* happen as we collect all the custom elements first in `initializeDocumentHydrate` + if (!hosts[slottedItem.hostId as any]) continue; + + const hostEle = hosts[slottedItem.hostId as any]; + + // This node is either slotted in a non-shadow host, OR *that* host is nested in a non-shadow host + if (!hostEle.shadowRoot || !shadowRoot) { + // Try to set an appropriate Content-position Reference (CR) node for this host element + + // Is a CR already set on the host? + slottedItem.slot['s-cr'] = hostEle['s-cr']; + + if (!slottedItem.slot['s-cr'] && hostEle.shadowRoot) { + // Host has shadowDOM - just use the host itself as the CR for native slotting + slottedItem.slot['s-cr'] = hostEle; + } else { + // If all else fails - just set the CR as the first child + // (9/10 if node['s-cr'] hasn't been set, the node will be at the element root) + const hostChildren = (hostEle as any).__childNodes || hostEle.childNodes; + slottedItem.slot['s-cr'] = hostChildren[0] as d.RenderNode; + } + // Create our 'Original Location' node + addSlotRelocateNode(slottedItem.node, slottedItem.slot, false, slottedItem.node['s-oo']); + } + + if (hostEle.shadowRoot && slottedItem.node.parentElement !== hostEle) { + // shadowDOM - move the item to the element root for native slotting + hostEle.appendChild(slottedItem.node); + } + } + } if (BUILD.shadowDom && shadowRoot) { - shadowRootNodes.map((shadowRootNode) => { - if (shadowRootNode) { - shadowRoot.appendChild(shadowRootNode as any); + // Add all the root nodes in the shadowDOM (a root node can have a whole nested DOM tree) + let rnIdex = 0; + const rnLen = shadowRootNodes.length; + for (rnIdex; rnIdex < rnLen; rnIdex++) { + shadowRoot.appendChild(shadowRootNodes[rnIdex] as any); + } + + // Tidy up left-over / unnecessary comments to stop frameworks complaining about DOM mismatches + Array.from(hostElm.childNodes).forEach((node) => { + if (node.nodeType === NODE_TYPE.CommentNode && typeof (node as d.RenderNode)['s-sn'] !== 'string') { + node.parentNode.removeChild(node); } }); } + + hostRef.$hostElement$ = hostElm; endHydrate(); }; /** * Recursively constructs the virtual node tree for a host element and its children. - * The tree is constructed by parsing the annotations set on the nodes by the server. + * The tree is constructed by parsing the annotations set on the nodes by the server (`vdom-annotations.ts`). * - * In addition to constructing the vNode tree, we also track information about the node's - * descendants like which are slots, which should exist in the shadow root, and which - * are nodes that should be rendered as children of the parent node. + * In addition to constructing the VNode tree, we also track information about the node's descendants: + * - which are slots + * - which should exist in the shadow root + * - which are nodes that should be rendered as children of the parent node * * @param parentVNode The vNode representing the parent node. * @param childRenderNodes An array of all child nodes in the parent's node tree. * @param slotNodes An array of all slot nodes in the parent's node tree. - * @param shadowRootNodes An array all nodes that should be rendered in the shadow root in the parent's node tree. + * @param shadowRootNodes An array of nodes that should be rendered in the shadowDOM of the parent. * @param hostElm The parent element. * @param node The node to construct the vNode tree for. * @param hostId The host ID assigned to the element by the server. + * @param slottedNodes - nodes that have been slotted + * @returns - the constructed VNode */ const clientHydrate = ( parentVNode: d.VNode, @@ -104,21 +218,23 @@ const clientHydrate = ( hostElm: d.HostElement, node: d.RenderNode, hostId: string, + slottedNodes: SlottedNodes[] = [], ) => { let childNodeType: string; let childIdSplt: string[]; let childVNode: RenderNodeData; let i: number; + const scopeId = hostElm['s-sc']; if (node.nodeType === NODE_TYPE.ElementNode) { childNodeType = (node as HTMLElement).getAttribute(HYDRATE_CHILD_ID); if (childNodeType) { - // got the node data from the element's attribute + // Node data from the element's attribute: // `${hostId}.${nodeId}.${depth}.${index}` childIdSplt = childNodeType.split('.'); if (childIdSplt[0] === hostId || childIdSplt[0] === '0') { - childVNode = { + childVNode = createSimpleVNode({ $flags$: 0, $hostId$: childIdSplt[0], $nodeId$: childIdSplt[1], @@ -126,26 +242,49 @@ const clientHydrate = ( $index$: childIdSplt[3], $tag$: node.tagName.toLowerCase(), $elm$: node, - $attrs$: null, - $children$: null, - $key$: null, - $name$: null, - $text$: null, - }; + // If we don't add the initial classes to the VNode, the first `vdom-render.ts` reconciliation will fail: + // client side changes before componentDidLoad will be ignored, `set-accessor.ts` will just take the element's initial classes + $attrs$: { class: node.className }, + }); childRenderNodes.push(childVNode); node.removeAttribute(HYDRATE_CHILD_ID); - // this is a new child vnode - // so ensure its parent vnode has the vchildren array + // This is a new child VNode so ensure its parent VNode has the VChildren array if (!parentVNode.$children$) { parentVNode.$children$ = []; } - // add our child vnode to a specific index of the vnode's children - parentVNode.$children$[childVNode.$index$ as any] = childVNode; + // Test if this element was 'slotted' or is a 'slot' (with fallback). Recreate node attributes + const slotName = childVNode.$elm$.getAttribute('s-sn'); + if (typeof slotName === 'string') { + if (childVNode.$tag$ === 'slot-fb') { + // This is a slot node. Set it up and find any assigned slotted nodes + addSlot( + slotName, + childIdSplt[2], + childVNode, + node, + parentVNode, + childRenderNodes, + slotNodes, + shadowRootNodes, + slottedNodes, + ); + } + childVNode.$elm$['s-sn'] = slotName; + childVNode.$elm$.removeAttribute('s-sn'); + } + if (childVNode.$index$ !== undefined) { + // add our child VNode to a specific index of the VNode's children + parentVNode.$children$[childVNode.$index$ as any] = childVNode; + } + + // Host is `scoped: true` - add that flag to the child. + // It's used in 'set-accessor.ts' to make sure our scoped class is present + if (scopeId) node['s-si'] = scopeId; - // this is now the new parent vnode for all the next child checks + // This is now the new parent VNode for all the next child checks parentVNode = childVNode; if (shadowRootNodes && childVNode.$depth$ === '0') { @@ -155,7 +294,7 @@ const clientHydrate = ( } if (node.shadowRoot) { - // keep drilling down through the shadow root nodes + // Keep drilling down through the shadow root nodes for (i = node.shadowRoot.childNodes.length - 1; i >= 0; i--) { clientHydrate( parentVNode, @@ -165,20 +304,23 @@ const clientHydrate = ( hostElm, node.shadowRoot.childNodes[i] as any, hostId, + slottedNodes, ); } } - // recursively drill down, end to start so we can remove nodes - for (i = node.childNodes.length - 1; i >= 0; i--) { + // Recursively drill down, end to start so we can remove nodes + const nonShadowNodes = node.__childNodes || node.childNodes; + for (i = nonShadowNodes.length - 1; i >= 0; i--) { clientHydrate( parentVNode, childRenderNodes, slotNodes, shadowRootNodes, hostElm, - node.childNodes[i] as any, + nonShadowNodes[i] as any, hostId, + slottedNodes, ); } } else if (node.nodeType === NODE_TYPE.CommentNode) { @@ -186,15 +328,14 @@ const clientHydrate = ( childIdSplt = node.nodeValue.split('.'); if (childIdSplt[1] === hostId || childIdSplt[1] === '0') { - // comment node for either the host id or a 0 host id + // A comment node for either this host OR (if 0) a root component childNodeType = childIdSplt[0]; - childVNode = { - $flags$: 0, + childVNode = createSimpleVNode({ $hostId$: childIdSplt[1], $nodeId$: childIdSplt[2], $depth$: childIdSplt[3], - $index$: childIdSplt[4], + $index$: childIdSplt[4] || '0', $elm$: node, $attrs$: null, $children$: null, @@ -202,71 +343,68 @@ const clientHydrate = ( $name$: null, $tag$: null, $text$: null, - }; + }); if (childNodeType === TEXT_NODE_ID) { childVNode.$elm$ = node.nextSibling as any; + if (childVNode.$elm$ && childVNode.$elm$.nodeType === NODE_TYPE.TextNode) { childVNode.$text$ = childVNode.$elm$.textContent; childRenderNodes.push(childVNode); - // remove the text comment since it's no longer needed + // Remove the text comment since it's no longer needed node.remove(); - if (!parentVNode.$children$) { - parentVNode.$children$ = []; + // Checks to make sure this node actually belongs to this host. + // If it was slotted from another component, we don't want to add it to this host's VDOM; it can be removed on render reconciliation. + // We *want* slotting logic to take care of it + if (hostId === childVNode.$hostId$) { + if (!parentVNode.$children$) { + parentVNode.$children$ = []; + } + parentVNode.$children$[childVNode.$index$ as any] = childVNode; } - parentVNode.$children$[childVNode.$index$ as any] = childVNode; if (shadowRootNodes && childVNode.$depth$ === '0') { shadowRootNodes[childVNode.$index$ as any] = childVNode.$elm$; } } + } else if (childNodeType === COMMENT_NODE_ID) { + childVNode.$elm$ = node.nextSibling as any; + + if (childVNode.$elm$ && childVNode.$elm$.nodeType === NODE_TYPE.CommentNode) { + // A non-Rindo comment node + childRenderNodes.push(childVNode); + + // Remove the comment comment since it's no longer needed + node.remove(); + } } else if (childVNode.$hostId$ === hostId) { - // this comment node is specifically for this host id + // This comment node is specifically for this host id if (childNodeType === SLOT_NODE_ID) { + // Comment refers to a slot node: // `${SLOT_NODE_ID}.${hostId}.${nodeId}.${depth}.${index}.${slotName}`; childVNode.$tag$ = 'slot'; - if (childIdSplt[5]) { - node['s-sn'] = childVNode.$name$ = childIdSplt[5]; - } else { - node['s-sn'] = ''; - } - node['s-sr'] = true; - - if (BUILD.shadowDom && shadowRootNodes) { - // browser support shadowRoot and this is a shadow dom component - // create an actual slot element - childVNode.$elm$ = doc.createElement(childVNode.$tag$); - - if (childVNode.$name$) { - // add the slot name attribute - childVNode.$elm$.setAttribute('name', childVNode.$name$); - } - - // insert the new slot element before the slot comment - node.parentNode.insertBefore(childVNode.$elm$, node); - - // remove the slot comment since it's not needed for shadow - node.remove(); - - if (childVNode.$depth$ === '0') { - shadowRootNodes[childVNode.$index$ as any] = childVNode.$elm$; - } - } - - slotNodes.push(childVNode); - - if (!parentVNode.$children$) { - parentVNode.$children$ = []; - } - parentVNode.$children$[childVNode.$index$ as any] = childVNode; + // Add the slot name + const slotName = (node['s-sn'] = childVNode.$name$ = childIdSplt[5] || ''); + // add the `` node to the VNode tree and prepare any slotted any child nodes + addSlot( + slotName, + childIdSplt[2], + childVNode, + node, + parentVNode, + childRenderNodes, + slotNodes, + shadowRootNodes, + slottedNodes, + ); } else if (childNodeType === CONTENT_REF_ID) { // `${CONTENT_REF_ID}.${hostId}`; if (BUILD.shadowDom && shadowRootNodes) { - // remove the content ref comment since it's not needed for shadow + // Remove the content ref comment since it's not needed for shadow node.remove(); } else if (BUILD.slotRelocation) { hostElm['s-cr'] = node; @@ -281,25 +419,35 @@ const clientHydrate = ( vnode.$index$ = '0'; parentVNode.$children$ = [vnode]; } + + return parentVNode; }; /** - * Recursively locate any comments representing an original location for a node in a node's - * children or shadowRoot children. + * Recursively locate any comments representing an 'original location' for a node; in a node's children or shadowRoot children. + * Creates a map of component IDs and 'original location' ID's which are derived from comment nodes placed by 'vdom-annotations.ts'. + * Each 'original location' relates to lightDOM node that was moved deeper into the SSR markup. e.g. `` maps to `
` * * @param node The node to search. - * @param orgLocNodes A map of the original location annotation and the current node being searched. + * @param orgLocNodes A map of the original location annotations and the current node being searched. */ export const initializeDocumentHydrate = (node: d.RenderNode, orgLocNodes: d.PlatformRuntime['$orgLocNodes$']) => { if (node.nodeType === NODE_TYPE.ElementNode) { + // Add all the loaded component IDs in this document; required to find nodes later when deciding where slotted nodes should live + const componentId = node[HYDRATE_ID] || node.getAttribute(HYDRATE_ID); + if (componentId) { + orgLocNodes.set(componentId, node); + } + let i = 0; if (node.shadowRoot) { for (; i < node.shadowRoot.childNodes.length; i++) { initializeDocumentHydrate(node.shadowRoot.childNodes[i] as d.RenderNode, orgLocNodes); } } - for (i = 0; i < node.childNodes.length; i++) { - initializeDocumentHydrate(node.childNodes[i] as d.RenderNode, orgLocNodes); + const nonShadowNodes = node.__childNodes || node.childNodes; + for (i = 0; i < nonShadowNodes.length; i++) { + initializeDocumentHydrate(nonShadowNodes[i] as d.RenderNode, orgLocNodes); } } else if (node.nodeType === NODE_TYPE.CommentNode) { const childIdSplt = node.nodeValue.split('.'); @@ -307,13 +455,145 @@ export const initializeDocumentHydrate = (node: d.RenderNode, orgLocNodes: d.Pla orgLocNodes.set(childIdSplt[1] + '.' + childIdSplt[2], node); node.nodeValue = ''; - // useful to know if the original location is - // the root light-dom of a shadow dom component + // Useful to know if the original location is The root light-dom of a shadow dom component node['s-en'] = childIdSplt[3] as any; } } }; +/** + * Creates a VNode to add to a hydrated component VDOM + * + * @param vnode - a vnode partial which will be augmented + * @returns an complete vnode + */ +const createSimpleVNode = (vnode: Partial): RenderNodeData => { + const defaultVNode: RenderNodeData = { + $flags$: 0, + $hostId$: null, + $nodeId$: null, + $depth$: null, + $index$: '0', + $elm$: null, + $attrs$: null, + $children$: null, + $key$: null, + $name$: null, + $tag$: null, + $text$: null, + }; + return { ...defaultVNode, ...vnode }; +}; + +function addSlot( + slotName: string, + slotId: string, + childVNode: RenderNodeData, + node: d.RenderNode, + parentVNode: d.VNode, + childRenderNodes: RenderNodeData[], + slotNodes: RenderNodeData[], + shadowRootNodes: d.RenderNode[], + slottedNodes: SlottedNodes[], +) { + node['s-sr'] = true; + + // Find this slots' current host parent (as dictated by the VDOM tree). + // Important because where it is now in the constructed SSR markup might be different to where to *should* be + const parentNodeId = parentVNode?.$elm$ ? parentVNode.$elm$['s-id'] || parentVNode.$elm$.getAttribute('s-id') : ''; + + if (BUILD.shadowDom && shadowRootNodes) { + /* SHADOW */ + + // Browser supports shadowRoot and this is a shadow dom component; create an actual slot element + const slot = (childVNode.$elm$ = doc.createElement(childVNode.$tag$ as string) as d.RenderNode); + + if (childVNode.$name$) { + // Add the slot name attribute + childVNode.$elm$.setAttribute('name', slotName); + } + + if (parentNodeId && parentNodeId !== childVNode.$hostId$) { + // Shadow component's slot is placed inside a nested component's shadowDOM; it doesn't belong to this host - it was forwarded by the SSR markup. + // Insert it in the root of this host; it's lightDOM. It doesn't really matter where in the host root; the component will take care of it. + parentVNode.$elm$.insertBefore(slot, parentVNode.$elm$.children[0]); + } else { + // Insert the new slot element before the slot comment + node.parentNode.insertBefore(childVNode.$elm$, node); + } + addSlottedNodes(slottedNodes, slotId, slotName, node, childVNode.$hostId$); + + // Remove the slot comment since it's not needed for shadow + node.remove(); + + if (childVNode.$depth$ === '0') { + shadowRootNodes[childVNode.$index$ as any] = childVNode.$elm$; + } + } else { + /* NON-SHADOW */ + const slot = childVNode.$elm$ as d.RenderNode; + + // Test to see if this non-shadow component's mock 'slot' is placed inside a nested component's shadowDOM. If so, it doesn't belong here; + // it was forwarded by the SSR markup. So we'll insert it into the root of this host; it's lightDOM with accompanying 'slotted' nodes + const shouldMove = parentNodeId && parentNodeId !== childVNode.$hostId$ && parentVNode.$elm$.shadowRoot; + + // attempt to find any mock slotted nodes which we'll move later + addSlottedNodes(slottedNodes, slotId, slotName, node, shouldMove ? parentNodeId : childVNode.$hostId$); + + if (shouldMove) { + // Move slot comment node (to after any other comment nodes) + parentVNode.$elm$.insertBefore(slot, parentVNode.$elm$.children[0]); + } + childRenderNodes.push(childVNode); + } + + slotNodes.push(childVNode); + + if (!parentVNode.$children$) { + parentVNode.$children$ = []; + } + parentVNode.$children$[childVNode.$index$ as any] = childVNode; +} + +/** + * Adds groups of slotted nodes (grouped by slot ID) to this host element's 'master' array. + * We'll use this after the host element's VDOM is completely constructed to finally position and add meta required by non-shadow slotted nodes + * + * @param slottedNodes - the main host element 'master' array to add to + * @param slotNodeId - the slot node unique ID + * @param slotName - the slot node name (can be '') + * @param slotNode - the slot node + * @param hostId - the host element id where this node should be slotted + */ +const addSlottedNodes = ( + slottedNodes: SlottedNodes[], + slotNodeId: string, + slotName: string, + slotNode: d.RenderNode, + hostId: string, +) => { + let slottedNode = slotNode.nextSibling as d.RenderNode; + slottedNodes[slotNodeId as any] = slottedNodes[slotNodeId as any] || []; + + // Looking for nodes that match this slot's name, + // OR are text / comment nodes and the slot is a default slot (no name) - text / comments cannot be direct descendants of *named* slots. + // Also ignore slot fallback nodes - they're not part of the lightDOM + while ( + slottedNode && + (((slottedNode['getAttribute'] && slottedNode.getAttribute('slot')) || slottedNode['s-sn']) === slotName || + (slotName === '' && + !slottedNode['s-sn'] && + ((slottedNode.nodeType === NODE_TYPE.CommentNode && slottedNode.nodeValue.indexOf('.') !== 1) || + slottedNode.nodeType === NODE_TYPE.TextNode))) + ) { + slottedNode['s-sn'] = slotName; + slottedNodes[slotNodeId as any].push({ slot: slotNode, node: slottedNode, hostId }); + slottedNode = slottedNode.nextSibling as d.RenderNode; + } +}; + +type SlottedNodes = Array<{ slot: d.RenderNode; node: d.RenderNode; hostId: string }>; + interface RenderNodeData extends d.VNode { $hostId$: string; $nodeId$: string; diff --git a/src/runtime/connected-callback.ts b/src/runtime/connected-callback.ts index aa50c7b1..e9db42f0 100644 --- a/src/runtime/connected-callback.ts +++ b/src/runtime/connected-callback.ts @@ -7,7 +7,7 @@ import { initializeClientHydrate } from './client-hydrate'; import { fireConnectedCallback, initializeComponent } from './initialize-component'; import { createTime } from './profile'; import { HYDRATE_ID, NODE_TYPE, PLATFORM_FLAGS } from './runtime-constants'; -import { addStyle } from './styles'; +import { addStyle, getScopeId } from './styles'; import { attachToAncestor } from './update-component'; import { insertBefore } from './vdom/vdom-render'; @@ -35,6 +35,11 @@ export const connectedCallback = (elm: d.HostElement) => { ? addStyle(elm.shadowRoot, cmpMeta, elm.getAttribute('s-mode')) : addStyle(elm.shadowRoot, cmpMeta); elm.classList.remove(scopeId + '-h', scopeId + '-s'); + } else if (BUILD.scoped && cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation) { + // set the scope id on the element now. Useful when hydrating, + // to more quickly set the initial scoped classes for scoped css + const scopeId = getScopeId(cmpMeta, BUILD.mode ? elm.getAttribute('s-mode') : undefined); + elm['s-sc'] = scopeId; } initializeClientHydrate(elm, cmpMeta.$tagName$, hostId, hostRef); } diff --git a/src/runtime/dom-extras.ts b/src/runtime/dom-extras.ts index 222c4311..4b414365 100644 --- a/src/runtime/dom-extras.ts +++ b/src/runtime/dom-extras.ts @@ -287,9 +287,15 @@ export const patchChildSlotNodes = (elm: HTMLElement) => { // for mock-doc childNodesFn = Object.getOwnPropertyDescriptor(elm, 'childNodes'); } - if (childNodesFn) Object.defineProperty(elm, '__childNodes', childNodesFn); + let childrenFn = Object.getOwnPropertyDescriptor(Element.prototype, 'children'); + if (!childrenFn) { + // for mock-doc + childrenFn = Object.getOwnPropertyDescriptor(elm, 'children'); + } + if (childrenFn) Object.defineProperty(elm, '__children', childrenFn); + Object.defineProperty(elm, 'children', { get() { return this.childNodes.filter((n: any) => n.nodeType === 1); @@ -330,9 +336,15 @@ export const patchChildSlotNodes = (elm: HTMLElement) => { * @param newChild a node that's going to be added to the component * @param slotNode the slot node that the node will be added to * @param prepend move the slotted location node to the beginning of the host + * @param position an ordered position to add the ref node which mirrors the lightDom nodes' order. Used during SSR hydration * (the order of the slot location nodes determines the order of the slotted nodes in our patched accessors) */ -export const addSlotRelocateNode = (newChild: d.RenderNode, slotNode: d.RenderNode, prepend?: boolean) => { +export const addSlotRelocateNode = ( + newChild: d.RenderNode, + slotNode: d.RenderNode, + prepend?: boolean, + position?: number, +) => { let slottedNodeLocation: d.RenderNode; // does newChild already have a slot location node? if (newChild['s-ol'] && newChild['s-ol'].isConnected) { @@ -342,13 +354,33 @@ export const addSlotRelocateNode = (newChild: d.RenderNode, slotNode: d.RenderNo slottedNodeLocation['s-nr'] = newChild; } + if (!slotNode['s-cr'] || !slotNode['s-cr'].parentNode) return; + const parent = slotNode['s-cr'].parentNode as any; - const appendMethod = prepend ? parent.__prepend : parent.__appendChild; + const appendMethod = prepend ? parent.__prepend || parent.prepend : parent.__appendChild || parent.appendChild; + + if (typeof position !== 'undefined') { + if (BUILD.hydrateClientSide) { + slottedNodeLocation['s-oo'] = position; + const childNodes = (parent.__childNodes || parent.childNodes) as NodeListOf; + const slotRelocateNodes: d.RenderNode[] = [slottedNodeLocation]; + childNodes.forEach((n) => { + if (n['s-nr']) slotRelocateNodes.push(n); + }); + + slotRelocateNodes.sort((a, b) => { + if (!a['s-oo'] || a['s-oo'] < b['s-oo']) return -1; + else if (!b['s-oo'] || b['s-oo'] < a['s-oo']) return 1; + return 0; + }); + slotRelocateNodes.forEach((n) => appendMethod.call(parent, n)); + } + } else { + appendMethod.call(parent, slottedNodeLocation); + } newChild['s-ol'] = slottedNodeLocation; newChild['s-sh'] = slotNode['s-hn']; - - appendMethod.call(parent, slottedNodeLocation); }; /** diff --git a/src/runtime/runtime-constants.ts b/src/runtime/runtime-constants.ts index 900a0b24..3a542387 100644 --- a/src/runtime/runtime-constants.ts +++ b/src/runtime/runtime-constants.ts @@ -54,6 +54,7 @@ export const CONTENT_REF_ID = 'r'; export const ORG_LOCATION_ID = 'o'; export const SLOT_NODE_ID = 's'; export const TEXT_NODE_ID = 't'; +export const COMMENT_NODE_ID = 'c'; export const HYDRATE_ID = 's-id'; export const HYDRATED_STYLE_ID = 'sty-id'; diff --git a/src/runtime/test/hydrate-no-encapsulation.spec.tsx b/src/runtime/test/hydrate-no-encapsulation.spec.tsx index eb5b44aa..5cb4d25a 100644 --- a/src/runtime/test/hydrate-no-encapsulation.spec.tsx +++ b/src/runtime/test/hydrate-no-encapsulation.spec.tsx @@ -211,8 +211,6 @@ describe('hydrate no encapsulation', () => { - - light-dom
@@ -272,8 +270,6 @@ describe('hydrate no encapsulation', () => { - - light-dom
@@ -334,9 +330,7 @@ describe('hydrate no encapsulation', () => { -
- light-dom
@@ -397,9 +391,7 @@ describe('hydrate no encapsulation', () => { -
- light-dom
@@ -479,15 +471,11 @@ describe('hydrate no encapsulation', () => { - - -
top light-dom
- middle light-dom
diff --git a/src/runtime/test/hydrate-scoped.spec.tsx b/src/runtime/test/hydrate-scoped.spec.tsx index 62bfd503..341eb791 100644 --- a/src/runtime/test/hydrate-scoped.spec.tsx +++ b/src/runtime/test/hydrate-scoped.spec.tsx @@ -44,9 +44,7 @@ describe('hydrate scoped', () => { expect(clientHydrated.root).toEqualHtml(` -
- 88mph
@@ -97,9 +95,7 @@ describe('hydrate scoped', () => { expect(clientHydrated.root).toEqualHtml(` - -
- +
88mph
@@ -133,7 +129,6 @@ describe('hydrate scoped', () => { `); - // @ts-ignore const clientHydrated = await newSpecPage({ components: [CmpA], html: serverHydrated.root.outerHTML, diff --git a/src/runtime/test/hydrate-shadow-child.spec.tsx b/src/runtime/test/hydrate-shadow-child.spec.tsx index 6dabb016..2dce3cba 100644 --- a/src/runtime/test/hydrate-shadow-child.spec.tsx +++ b/src/runtime/test/hydrate-shadow-child.spec.tsx @@ -102,7 +102,6 @@ describe('hydrate, shadow child', () => { - light-dom @@ -285,7 +284,6 @@ describe('hydrate, shadow child', () => {
- light-dom @@ -417,7 +415,6 @@ describe('hydrate, shadow child', () => {
- light-dom @@ -428,7 +425,11 @@ describe('hydrate, shadow child', () => { @Component({ tag: 'cmp-a' }) class CmpA { render() { - return ; + return ( + + + + ); } } @Component({ tag: 'cmp-b', shadow: true }) @@ -469,15 +470,17 @@ describe('hydrate, shadow child', () => { expect(serverHydrated.root).toEqualHtml(` - + + + - - + +
- + cmp-b-top-text - +
@@ -497,18 +500,16 @@ describe('hydrate, shadow child', () => { }); expect(clientHydrated.root).toEqualHtml(` - + - +
- cmp-b-top-text - - +
cmp-c diff --git a/src/runtime/test/hydrate-shadow-in-shadow.spec.tsx b/src/runtime/test/hydrate-shadow-in-shadow.spec.tsx index 56af7ea9..b61363da 100644 --- a/src/runtime/test/hydrate-shadow-in-shadow.spec.tsx +++ b/src/runtime/test/hydrate-shadow-in-shadow.spec.tsx @@ -60,17 +60,14 @@ describe('hydrate, shadow in shadow', () => { - - light-dom `); expect(clientHydrated.root).toEqualLightHtml(` - light-dom `); @@ -130,7 +127,6 @@ describe('hydrate, shadow in shadow', () => { - light-dom @@ -314,7 +310,6 @@ describe('hydrate, shadow in shadow', () => {
- light-dom @@ -443,7 +438,7 @@ describe('hydrate, shadow in shadow', () => {
- light-dom + light-dom `); diff --git a/src/runtime/test/hydrate-shadow-parent.spec.tsx b/src/runtime/test/hydrate-shadow-parent.spec.tsx index a9e1ae40..82017af4 100644 --- a/src/runtime/test/hydrate-shadow-parent.spec.tsx +++ b/src/runtime/test/hydrate-shadow-parent.spec.tsx @@ -54,7 +54,6 @@ describe('hydrate, shadow parent', () => {
- middle `); @@ -114,7 +113,6 @@ describe('hydrate, shadow parent', () => { bottom - middle `); @@ -272,8 +270,6 @@ describe('hydrate, shadow parent', () => { - - cmp-a-light-dom @@ -351,13 +347,11 @@ describe('hydrate, shadow parent', () => { - Title `); expect(clientHydrated.root).toEqualLightHtml(` - Title `); @@ -409,7 +403,7 @@ describe('hydrate, shadow parent', () => { - + @@ -441,12 +435,10 @@ describe('hydrate, shadow parent', () => { - - root-text @@ -482,7 +474,7 @@ describe('hydrate, shadow parent', () => { - + @@ -504,11 +496,8 @@ describe('hydrate, shadow parent', () => { - - - cmp-a-light-dom @@ -516,11 +505,8 @@ describe('hydrate, shadow parent', () => { expect(clientHydrated.root).toEqualLightHtml(` - - - cmp-a-light-dom diff --git a/src/runtime/test/hydrate-shadow.spec.tsx b/src/runtime/test/hydrate-shadow.spec.tsx index c38f5106..a79ffe81 100644 --- a/src/runtime/test/hydrate-shadow.spec.tsx +++ b/src/runtime/test/hydrate-shadow.spec.tsx @@ -61,7 +61,6 @@ describe('hydrate, shadow', () => { - CmpALightDom @@ -119,7 +118,7 @@ describe('hydrate, shadow', () => {
-
+

LightDom1 @@ -157,7 +156,6 @@ describe('hydrate, shadow', () => { -

diff --git a/src/runtime/test/hydrate-slot-fallback.spec.tsx b/src/runtime/test/hydrate-slot-fallback.spec.tsx new file mode 100644 index 00000000..aa3b4725 --- /dev/null +++ b/src/runtime/test/hydrate-slot-fallback.spec.tsx @@ -0,0 +1,435 @@ +import { Component, h } from '@rindo/core'; +import { newSpecPage } from '@rindo/core/testing'; + +describe('hydrate, slot fallback', () => { + it('shows slot fallback content in a `scoped: true` parent', async () => { + @Component({ + tag: 'cmp-a', + scoped: true, + }) + class CmpA { + render() { + return ( +

+ + Fallback text - should not be hidden + Fallback element + +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ``, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + +
+ + + Fallback text - should not be hidden + + + Fallback element + + +
+
+ `); + + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root).toEqualHtml(` + + +
+ + Fallback text - should not be hidden + + Fallback element + + +
+
+ `); + }); + + it('shows slot fallback content in a `shadow: true` component`', async () => { + @Component({ + tag: 'cmp-a', + shadow: true, + }) + class CmpA { + render() { + return ( +
+ + Fallback text - should not be hidden + Fallback element + +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ``, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + +
+ + + Fallback text - should not be hidden + + + Fallback element + + +
+
+ `); + + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root).toEqualHtml(` + + +
+ + Fallback text - should not be hidden + + Fallback element + + +
+
+
+ `); + }); + + it('shows slot fallback text in a nested `scoped: true` component (hides the fallback in the `scoped: true` parent component)', async () => { + @Component({ + tag: 'cmp-a', + scoped: true, + }) + class CmpA { + render() { + return ( +
+ Fallback content parent - should be hidden +

Non slot based content

+
+ ); + } + } + + @Component({ + tag: 'cmp-b', + scoped: true, + }) + class CmpB { + render() { + return ( +
+ Fallback content child - should not be hidden +

Non slot based content

+
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: ``, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + +
+ + +
+ + + Fallback content child - should not be hidden + +

+ + Non slot based content +

+
+
+ +

+ + Non slot based content +

+
+
+ `); + + const clientHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root.outerHTML).toEqualHtml(` + + +
+ + +
+ + Fallback content child - should not be hidden + +

+ Non slot based content +

+
+
+ +

+ Non slot based content +

+
+
+ `); + }); + + it('renders slot fallback text in a nested `shadow: true` component (`shadow: true` parent component)', async () => { + @Component({ + tag: 'cmp-a', + shadow: true, + }) + class CmpA { + render() { + return ( +
+ Fallback content parent - should be hidden +

Non slot based content

+
+ ); + } + } + + @Component({ + tag: 'cmp-b', + shadow: true, + }) + class CmpB { + render() { + return ( +
+ Fallback content child - should not be hidden +

Non slot based content

+
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: ``, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + +
+ + +
+ + + Fallback content child - should not be hidden + +

+ + Non slot based content +

+
+
+ +

+ + Non slot based content +

+
+
+ `); + + const clientHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root).toEqualHtml(` + + +
+ + Fallback content parent - should be hidden + +

+ Non slot based content +

+
+
+ + +
+ + Fallback content child - should not be hidden + +

+ Non slot based content +

+
+
+
+
+ `); + }); + + it('does not show slot fallback text when a `scoped: true` component forwards the slot to nested `shadow: true`', async () => { + @Component({ + tag: 'cmp-a', + scoped: true, + }) + class CmpA { + render() { + return ( +
+ + Fallback content parent - should be hidden + +
+ ); + } + } + + @Component({ + tag: 'cmp-b', + shadow: true, + }) + class CmpB { + render() { + return ( +
+ Fallback content child - should be hidden +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: ` + +

slotted item 1

+

slotted item 2

+

slotted item 3

+
+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + +
+ + + +
+

+ slotted item 1 +

+

+ slotted item 2 +

+

+ slotted item 3 +

+ + +
+
+
+
+ `); + + const clientHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root).toEqualHtml(` + + +
+ + +
+ + Fallback content child - should be hidden + +
+
+ +

+ slotted item 1 +

+

+ slotted item 2 +

+

+ slotted item 3 +

+
+
+
+ `); + }); +}); diff --git a/src/runtime/test/hydrate-slotted-content-order.spec.tsx b/src/runtime/test/hydrate-slotted-content-order.spec.tsx new file mode 100644 index 00000000..28a53a26 --- /dev/null +++ b/src/runtime/test/hydrate-slotted-content-order.spec.tsx @@ -0,0 +1,541 @@ +import { Component, h } from '@rindo/core'; +import { newSpecPage } from '@rindo/core/testing'; + +import { patchPseudoShadowDom } from '../../runtime/dom-extras'; + +describe("hydrated components' slotted node order", () => { + const nodeOrEle = (node: Node | Element) => { + if (!node) return ''; + return (node as Element).outerHTML || node.nodeValue; + }; + + it('should retain original order of slotted nodes within a `shadow: true` component', async () => { + @Component({ + tag: 'cmp-a', + shadow: true, + }) + class CmpA { + render() { + return ( +
+ +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ` +

slotted item 1

slotted item 2

A text node

slotted item 3

+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + + + + +
+ +

+ slotted item 1 +

+ + +

+ slotted item 2 +

+ + A text node +

+ slotted item 3 +

+ + +
+
`); + + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root).toEqualHtml(` + + +
+ +
+
+

+ slotted item 1 +

+ +

+ slotted item 2 +

+ A text node +

+ slotted item 3 +

+ +
+ `); + + const childNodes = clientHydrated.root.childNodes; + + expect(nodeOrEle(childNodes[0])).toBe(`

slotted item 1

`); + expect(nodeOrEle(childNodes[1])).toBe(` a comment `); + expect(nodeOrEle(childNodes[2])).toBe(`

slotted item 2

`); + expect(nodeOrEle(childNodes[3])).toBe(`A text node`); + expect(nodeOrEle(childNodes[4])).toBe(`

slotted item 3

`); + expect(nodeOrEle(childNodes[5])).toBe(` another comment `); + }); + + it('should retain original order of slotted nodes within multiple slots of a `shadow: true` component', async () => { + @Component({ + tag: 'cmp-a', + shadow: true, + }) + class CmpA { + render() { + return ( +
+ +
+ +
+
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ` + Default slot

second slot

+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + + +
+ +
+ + + + + Default slot + + +
+
+
`); + + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root.outerHTML).toEqualHtml(` + + + Default slot +

+ second slot +

+ +
+ `); + + const childNodes = clientHydrated.root.childNodes; + + expect(nodeOrEle(childNodes[0])).toBe(` comment node `); + expect(nodeOrEle(childNodes[1])).toBe(` Default slot `); + expect(nodeOrEle(childNodes[2])).toBe(`

second slot

`); + expect(nodeOrEle(childNodes[3])).toBe(` another comment node `); + }); + + it('should retain original order of slotted nodes within nested `shadow: true` components', async () => { + @Component({ + tag: 'cmp-a', + shadow: true, + }) + class CmpA { + render() { + return ( +
+ +
+ ); + } + } + + @Component({ + tag: 'cmp-b', + shadow: true, + }) + class CmpB { + render() { + return ( +
+ +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: ` +

slotted item 1a

A text node

slotted item 1b

B text node
+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + + + +
+ +

+ slotted item 1a +

+ + + + A text node + + + + + + + + +
+ +

+ slotted item 1b +

+ + + + B text node + + +
+
+
+
`); + + const clientHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + const childNodes = clientHydrated.root.childNodes; + + expect(nodeOrEle(childNodes[0])).toBe(`

slotted item 1a

`); + expect(nodeOrEle(childNodes[1])).toBe(` a comment `); + expect(nodeOrEle(childNodes[2])).toBe(`A text node`); + expect(nodeOrEle(childNodes[3])).toBe(` another comment a`); + expect(nodeOrEle(childNodes[4].childNodes[0])).toBe(`

slotted item 1b

`); + expect(nodeOrEle(childNodes[4].childNodes[1])).toBe(` b comment `); + expect(nodeOrEle(childNodes[4].childNodes[2])).toBe(`B text node`); + expect(nodeOrEle(childNodes[4].childNodes[3])).toBe(` another comment b`); + }); + + it('should retain original order of slotted nodes within a `scoped: true` component', async () => { + @Component({ + tag: 'cmp-a', + scoped: true, + }) + class CmpA { + render() { + return ( +
+ +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ` +

slotted item 1

slotted item 2

A text node

slotted item 3

+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + + + + +
+ +

+ slotted item 1 +

+ + +

+ slotted item 2 +

+ + A text node +

+ slotted item 3 +

+ + +
+
`); + + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + // patches this element in the same way we patch all elements in the browser + patchPseudoShadowDom(clientHydrated.root); + + expect(clientHydrated.root.outerHTML).toEqualHtml(` + + +
+

+ slotted item 1 +

+ +

+ slotted item 2 +

+ A text node +

+ slotted item 3 +

+ +
+
+ `); + + const childNodes = clientHydrated.root.childNodes; + + expect(nodeOrEle(childNodes[0])).toBe(`

slotted item 1

`); + expect(nodeOrEle(childNodes[1])).toBe(` a comment `); + expect(nodeOrEle(childNodes[2])).toBe(`

slotted item 2

`); + expect(nodeOrEle(childNodes[3])).toBe(`A text node`); + expect(nodeOrEle(childNodes[4])).toBe(`

slotted item 3

`); + expect(nodeOrEle(childNodes[5])).toBe(` another comment `); + }); + + it('should retain original order of slotted nodes within multiple slots of a `scoped: true` component', async () => { + @Component({ + tag: 'cmp-a', + shadow: false, + }) + class CmpA { + render() { + return ( +
+ +
+ +
+
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ` + Default slot

second slot

+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + + +
+ +
+ + + + + Default slot + + +
+
+
`); + + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + // patches this element in the same way we patch all elements in the browser + patchPseudoShadowDom(clientHydrated.root); + + const childNodes = clientHydrated.root.childNodes; + + expect(nodeOrEle(childNodes[0])).toBe(` comment node `); + expect(nodeOrEle(childNodes[1])).toBe(` Default slot `); + expect(nodeOrEle(childNodes[2])).toBe(`

second slot

`); + expect(nodeOrEle(childNodes[3])).toBe(` another comment node `); + }); + + it('should retain original order of slotted nodes within nested `scoped: true` components', async () => { + @Component({ + tag: 'cmp-a', + shadow: false, + }) + class CmpA { + render() { + return ( +
+ +
+ ); + } + } + + @Component({ + tag: 'cmp-b', + shadow: false, + }) + class CmpB { + render() { + return ( +
+ +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: ` +

slotted item 1a

A text node

slotted item 1b

B text node
+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + + + +
+ +

+ slotted item 1a +

+ + + + A text node + + + + + + + + +
+ +

+ slotted item 1b +

+ + + + B text node + + +
+
+
+
`); + + const clientHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + // patches this element in the same way we patch all elements in the browser + patchPseudoShadowDom(clientHydrated.root); + + const childNodes = clientHydrated.root.childNodes; + + patchPseudoShadowDom(childNodes[4]); + + expect(nodeOrEle(childNodes[0])).toBe(`

slotted item 1a

`); + expect(nodeOrEle(childNodes[1])).toBe(` a comment `); + expect(nodeOrEle(childNodes[2])).toBe(`A text node`); + expect(nodeOrEle(childNodes[3])).toBe(` another comment a`); + expect(nodeOrEle(childNodes[4].childNodes[0])).toBe(`

slotted item 1b

`); + expect(nodeOrEle(childNodes[4].childNodes[1])).toBe(` b comment `); + expect(nodeOrEle(childNodes[4].childNodes[2])).toBe(`B text node`); + expect(nodeOrEle(childNodes[4].childNodes[3])).toBe(` another comment b`); + }); +}); diff --git a/src/runtime/vdom/set-accessor.ts b/src/runtime/vdom/set-accessor.ts index 295c112a..fd6de04b 100644 --- a/src/runtime/vdom/set-accessor.ts +++ b/src/runtime/vdom/set-accessor.ts @@ -11,6 +11,7 @@ import { BUILD } from '@app-data'; import { isMemberInElement, plt, win } from '@platform'; import { isComplexType } from '@utils'; +import type * as d from '../../declarations'; import { VNODE_FLAGS, XLINK_NS } from '../runtime-constants'; /** @@ -29,7 +30,7 @@ import { VNODE_FLAGS, XLINK_NS } from '../runtime-constants'; * @param flags bitflags for Vdom variables */ export const setAccessor = ( - elm: HTMLElement, + elm: d.RenderNode, memberName: string, oldValue: any, newValue: any, @@ -44,6 +45,11 @@ export const setAccessor = ( const classList = elm.classList; const oldClasses = parseClassList(oldValue); const newClasses = parseClassList(newValue); + // for `scoped: true` components, new nodes after initial hydration + // from SSR don't have the slotted class added. Let's add that now + if (elm['s-si'] && newClasses.indexOf(elm['s-si']) < 0) { + newClasses.push(elm['s-si']); + } classList.remove(...oldClasses.filter((c) => c && !newClasses.includes(c))); classList.add(...newClasses.filter((c) => c && !oldClasses.includes(c))); } else if (BUILD.vdomStyle && memberName === 'style') { diff --git a/src/runtime/vdom/vdom-annotations.ts b/src/runtime/vdom/vdom-annotations.ts index d9740128..a2f8a071 100644 --- a/src/runtime/vdom/vdom-annotations.ts +++ b/src/runtime/vdom/vdom-annotations.ts @@ -2,6 +2,7 @@ import { getHostRef } from '@platform'; import type * as d from '../../declarations'; import { + COMMENT_NODE_ID, CONTENT_REF_ID, DEFAULT_DOC_DATA, HYDRATE_CHILD_ID, @@ -51,6 +52,9 @@ export const insertVdomAnnotations = (doc: Document, staticComponents: string[]) if (nodeRef.nodeType === NODE_TYPE.ElementNode) { nodeRef.setAttribute(HYDRATE_CHILD_ID, childId); + if (typeof nodeRef['s-sn'] === 'string' && !nodeRef.getAttribute('slot')) { + nodeRef.setAttribute('s-sn', nodeRef['s-sn']); + } } else if (nodeRef.nodeType === NODE_TYPE.TextNode) { if (hostId === 0) { const textContent = nodeRef.nodeValue?.trim(); @@ -63,6 +67,10 @@ export const insertVdomAnnotations = (doc: Document, staticComponents: string[]) const commentBeforeTextNode = doc.createComment(childId); commentBeforeTextNode.nodeValue = `${TEXT_NODE_ID}.${childId}`; insertBefore(nodeRef.parentNode, commentBeforeTextNode, nodeRef); + } else if (nodeRef.nodeType === NODE_TYPE.CommentNode) { + const commentBeforeTextNode = doc.createComment(childId); + commentBeforeTextNode.nodeValue = `${COMMENT_NODE_ID}.${childId}`; + nodeRef.parentNode.insertBefore(commentBeforeTextNode, nodeRef); } } @@ -73,7 +81,7 @@ export const insertVdomAnnotations = (doc: Document, staticComponents: string[]) if (orgLocationParentNode['s-en'] === '') { // ending with a "." means that the parent element // of this node's original location is a SHADOW dom element - // and this node is apart of the root level light dom + // and this node is a part of the root level light dom orgLocationNodeId += `.`; } else if (orgLocationParentNode['s-en'] === 'c') { // ending with a ".c" means that the parent element @@ -222,6 +230,9 @@ const insertChildVNodeAnnotations = ( if (childElm.nodeType === NODE_TYPE.ElementNode) { childElm.setAttribute(HYDRATE_CHILD_ID, childId); + if (typeof childElm['s-sn'] === 'string' && !childElm.getAttribute('slot')) { + childElm.setAttribute('s-sn', childElm['s-sn']); + } } else if (childElm.nodeType === NODE_TYPE.TextNode) { const parentNode = childElm.parentNode; const nodeName = parentNode?.nodeName; diff --git a/src/runtime/vdom/vdom-render.ts b/src/runtime/vdom/vdom-render.ts index dab91d84..31dda023 100644 --- a/src/runtime/vdom/vdom-render.ts +++ b/src/runtime/vdom/vdom-render.ts @@ -775,7 +775,7 @@ export const updateFallbackSlotVisibility = (elm: d.RenderNode) => { childNode.hidden = true; break; } - } else { + } else if (slotName === siblingNode['s-sn']) { // this is a default fallback slot node // any element or text node (with content) // should hide the default fallback slot node diff --git a/test/end-to-end/rindo.config.ts b/test/end-to-end/rindo.config.ts index f5ea736e..ccfdeac7 100644 --- a/test/end-to-end/rindo.config.ts +++ b/test/end-to-end/rindo.config.ts @@ -61,4 +61,7 @@ export const config: Config = { hashFileNames: false, buildEs5: 'prod', sourceMap: true, + extras: { + experimentalSlotFixes: true, + }, }; diff --git a/test/end-to-end/src/components.d.ts b/test/end-to-end/src/components.d.ts index 3f33202c..365fbb78 100644 --- a/test/end-to-end/src/components.d.ts +++ b/test/end-to-end/src/components.d.ts @@ -106,6 +106,12 @@ export namespace Components { } interface NestedScopeCmp { } + interface NonShadowChild { + } + interface NonShadowForwardedSlot { + } + interface NonShadowWrapper { + } interface PathAliasCmp { } interface PrerenderCmp { @@ -133,6 +139,10 @@ export namespace Components { "cars": CarData[]; "selected": CarData; } + interface ShadowChild { + } + interface ShadowWrapper { + } interface SlotCmp { } interface SlotCmpContainer { @@ -363,6 +373,24 @@ declare global { prototype: HTMLNestedScopeCmpElement; new (): HTMLNestedScopeCmpElement; }; + interface HTMLNonShadowChildElement extends Components.NonShadowChild, HTMLRindoElement { + } + var HTMLNonShadowChildElement: { + prototype: HTMLNonShadowChildElement; + new (): HTMLNonShadowChildElement; + }; + interface HTMLNonShadowForwardedSlotElement extends Components.NonShadowForwardedSlot, HTMLRindoElement { + } + var HTMLNonShadowForwardedSlotElement: { + prototype: HTMLNonShadowForwardedSlotElement; + new (): HTMLNonShadowForwardedSlotElement; + }; + interface HTMLNonShadowWrapperElement extends Components.NonShadowWrapper, HTMLRindoElement { + } + var HTMLNonShadowWrapperElement: { + prototype: HTMLNonShadowWrapperElement; + new (): HTMLNonShadowWrapperElement; + }; interface HTMLPathAliasCmpElement extends Components.PathAliasCmp, HTMLRindoElement { } var HTMLPathAliasCmpElement: { @@ -407,6 +435,18 @@ declare global { prototype: HTMLScopedCarListElement; new (): HTMLScopedCarListElement; }; + interface HTMLShadowChildElement extends Components.ShadowChild, HTMLRindoElement { + } + var HTMLShadowChildElement: { + prototype: HTMLShadowChildElement; + new (): HTMLShadowChildElement; + }; + interface HTMLShadowWrapperElement extends Components.ShadowWrapper, HTMLRindoElement { + } + var HTMLShadowWrapperElement: { + prototype: HTMLShadowWrapperElement; + new (): HTMLShadowWrapperElement; + }; interface HTMLSlotCmpElement extends Components.SlotCmp, HTMLRindoElement { } var HTMLSlotCmpElement: { @@ -459,11 +499,16 @@ declare global { "nested-cmp-child": HTMLNestedCmpChildElement; "nested-cmp-parent": HTMLNestedCmpParentElement; "nested-scope-cmp": HTMLNestedScopeCmpElement; + "non-shadow-child": HTMLNonShadowChildElement; + "non-shadow-forwarded-slot": HTMLNonShadowForwardedSlotElement; + "non-shadow-wrapper": HTMLNonShadowWrapperElement; "path-alias-cmp": HTMLPathAliasCmpElement; "prerender-cmp": HTMLPrerenderCmpElement; "prop-cmp": HTMLPropCmpElement; "scoped-car-detail": HTMLScopedCarDetailElement; "scoped-car-list": HTMLScopedCarListElement; + "shadow-child": HTMLShadowChildElement; + "shadow-wrapper": HTMLShadowWrapperElement; "slot-cmp": HTMLSlotCmpElement; "slot-cmp-container": HTMLSlotCmpContainerElement; "slot-parent-cmp": HTMLSlotParentCmpElement; @@ -545,6 +590,12 @@ declare namespace LocalJSX { } interface NestedScopeCmp { } + interface NonShadowChild { + } + interface NonShadowForwardedSlot { + } + interface NonShadowWrapper { + } interface PathAliasCmp { } interface PrerenderCmp { @@ -573,6 +624,10 @@ declare namespace LocalJSX { "onCarSelected"?: (event: ScopedCarListCustomEvent) => void; "selected"?: CarData; } + interface ShadowChild { + } + interface ShadowWrapper { + } interface SlotCmp { } interface SlotCmpContainer { @@ -610,11 +665,16 @@ declare namespace LocalJSX { "nested-cmp-child": NestedCmpChild; "nested-cmp-parent": NestedCmpParent; "nested-scope-cmp": NestedScopeCmp; + "non-shadow-child": NonShadowChild; + "non-shadow-forwarded-slot": NonShadowForwardedSlot; + "non-shadow-wrapper": NonShadowWrapper; "path-alias-cmp": PathAliasCmp; "prerender-cmp": PrerenderCmp; "prop-cmp": PropCmp; "scoped-car-detail": ScopedCarDetail; "scoped-car-list": ScopedCarList; + "shadow-child": ShadowChild; + "shadow-wrapper": ShadowWrapper; "slot-cmp": SlotCmp; "slot-cmp-container": SlotCmpContainer; "slot-parent-cmp": SlotParentCmp; @@ -658,6 +718,9 @@ declare module "@rindo/core" { "nested-cmp-child": LocalJSX.NestedCmpChild & JSXBase.HTMLAttributes; "nested-cmp-parent": LocalJSX.NestedCmpParent & JSXBase.HTMLAttributes; "nested-scope-cmp": LocalJSX.NestedScopeCmp & JSXBase.HTMLAttributes; + "non-shadow-child": LocalJSX.NonShadowChild & JSXBase.HTMLAttributes; + "non-shadow-forwarded-slot": LocalJSX.NonShadowForwardedSlot & JSXBase.HTMLAttributes; + "non-shadow-wrapper": LocalJSX.NonShadowWrapper & JSXBase.HTMLAttributes; "path-alias-cmp": LocalJSX.PathAliasCmp & JSXBase.HTMLAttributes; "prerender-cmp": LocalJSX.PrerenderCmp & JSXBase.HTMLAttributes; "prop-cmp": LocalJSX.PropCmp & JSXBase.HTMLAttributes; @@ -666,6 +729,8 @@ declare module "@rindo/core" { * Component that helps display a list of cars */ "scoped-car-list": LocalJSX.ScopedCarList & JSXBase.HTMLAttributes; + "shadow-child": LocalJSX.ShadowChild & JSXBase.HTMLAttributes; + "shadow-wrapper": LocalJSX.ShadowWrapper & JSXBase.HTMLAttributes; "slot-cmp": LocalJSX.SlotCmp & JSXBase.HTMLAttributes; "slot-cmp-container": LocalJSX.SlotCmpContainer & JSXBase.HTMLAttributes; "slot-parent-cmp": LocalJSX.SlotParentCmp & JSXBase.HTMLAttributes; diff --git a/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts b/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts index 19d5ca0e..47e49cbd 100644 --- a/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts +++ b/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts @@ -355,7 +355,7 @@ describe('renderToString', () => {
- +
diff --git a/test/end-to-end/src/scoped-hydration/non-shadow-forwarded-slot.tsx b/test/end-to-end/src/scoped-hydration/non-shadow-forwarded-slot.tsx new file mode 100644 index 00000000..fbf3e974 --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/non-shadow-forwarded-slot.tsx @@ -0,0 +1,32 @@ +import { Component, Host, h } from '@rindo/core'; + +@Component({ + tag: 'non-shadow-forwarded-slot', + scoped: true, + styles: ` + :host { + display: block; + border: 3px solid red; + } + :host strong { + color: red; + } + `, +}) +export class Wrapper { + render() { + return ( + + Non shadow parent. Start. +
+ + + This is default content in the non-shadow parent slot + + +
+ Non shadow parent. End. +
+ ); + } +} diff --git a/test/end-to-end/src/scoped-hydration/non-shadow-wrapper.tsx b/test/end-to-end/src/scoped-hydration/non-shadow-wrapper.tsx new file mode 100644 index 00000000..48ab04fe --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/non-shadow-wrapper.tsx @@ -0,0 +1,25 @@ +import { Component, Host, h } from '@rindo/core'; + +@Component({ + tag: 'non-shadow-wrapper', + scoped: true, + styles: ` + :host { + display: block; + border: 3px solid red; + } + `, +}) +export class Wrapper { + render() { + return ( + + Non-shadow Wrapper Start +

Wrapper Slot before

+ Wrapper Slot Fallback +

Wrapper Slot after

+ Non-shadow Wrapper End +
+ ); + } +} diff --git a/test/end-to-end/src/scoped-hydration/non-shadow.tsx b/test/end-to-end/src/scoped-hydration/non-shadow.tsx new file mode 100644 index 00000000..586ff9ec --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/non-shadow.tsx @@ -0,0 +1,25 @@ +import { Component, Host, h } from '@rindo/core'; + +@Component({ + tag: 'non-shadow-child', + scoped: true, + styles: ` + :host { + display: block; + border: 3px solid blue; + } + `, +}) +export class MyApp { + render() { + return ( + +
+ Nested Non-Shadow Component Start +
+ Slotted fallback content + Nested Non-Shadow Component End +
+ ); + } +} diff --git a/test/end-to-end/src/scoped-hydration/readme.md b/test/end-to-end/src/scoped-hydration/readme.md new file mode 100644 index 00000000..156e82dd --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/readme.md @@ -0,0 +1,8 @@ +# shadow-wrapper + + + + +---------------------------------------------- + +*Built with [RindoJS](https://rindojs.web.app/)* diff --git a/test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts b/test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts new file mode 100644 index 00000000..8c4e85ab --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts @@ -0,0 +1,109 @@ +import { newE2EPage, E2EPage } from '@rindo/core/testing'; + +// @ts-ignore may not be existing when project hasn't been built +type HydrateModule = typeof import('../../hydrate'); +let renderToString: HydrateModule['renderToString']; + +async function getElementOrder(page: E2EPage, parent: string) { + return await page.evaluate((parent: string) => { + const external = Array.from(document.querySelector(parent).children).map((el) => el.tagName); + const internal = Array.from((document.querySelector(parent) as any).__children).map((el: Element) => el.tagName); + return { internal, external }; + }, parent); +} + +describe('`scoped: true` hydration checks', () => { + beforeAll(async () => { + // @ts-ignore may not be existing when project hasn't been built + const mod = await import('../../hydrate'); + renderToString = mod.renderToString; + }); + + it('shows fallback slot when no content is slotted', async () => { + const { html } = await renderToString( + ` + + test + `, + { + serializeShadowRoot: true, + }, + ); + expect(html).toContain('Slotted fallback content'); + const page = await newE2EPage({ html, url: 'https://rindojs.web.app' }); + const slots = await page.findAll('slot-fb'); + expect(await slots[0].getAttribute('hidden')).toBeNull(); + expect(await slots[1].getAttribute('hidden')).not.toBeNull(); + }); + + it('keeps slotted elements in their assigned position and does not duplicate slotted children', async () => { + const { html } = await renderToString( + ` + + + + `, + { + serializeShadowRoot: true, + }, + ); + const page = await newE2EPage({ html, url: 'https://rindojs.web.app' }); + + const { external, internal } = await getElementOrder(page, 'non-shadow-wrapper'); + expect(external.length).toBe(1); + expect(internal.length).toBe(6); + + expect(internal).toEqual(['STRONG', 'P', 'SLOT-FB', 'NON-SHADOW-CHILD', 'P', 'STRONG']); + expect(external).toEqual(['NON-SHADOW-CHILD']); + + const slots = await page.findAll('slot-fb'); + expect(await slots[0].getAttribute('hidden')).not.toBeNull(); + expect(await slots[1].getAttribute('hidden')).toBeNull(); + }); + + it('forwards slotted nodes into a nested shadow component whilst keeping those nodes in the light dom', async () => { + const { html } = await renderToString( + ` + +

slotted item 1

+

slotted item 2

+

slotted item 3

+
+ `, + { + serializeShadowRoot: true, + }, + ); + const page = await newE2EPage({ html, url: 'https://rindojs.web.app' }); + + const { external, internal } = await getElementOrder(page, 'non-shadow-forwarded-slot'); + expect(external.length).toBe(3); + expect(internal.length).toBe(5); + + expect(internal).toEqual(['STRONG', 'BR', 'SHADOW-CHILD', 'BR', 'STRONG']); + expect(external).toEqual(['P', 'P', 'P']); + }); + + it('retains the correct order of different nodes', async () => { + const { html } = await renderToString( + ` + + Text node 1 + +

Slotted element 1

+

Slotted element 2

+ + Text node 2 +
+ `, + { + serializeShadowRoot: true, + }, + ); + const page = await newE2EPage({ html, url: 'https://rindojs.web.app' }); + + expect(await page.evaluate(() => document.querySelector('non-shadow-forwarded-slot').textContent.trim())).toContain( + 'Text node 1 Comment 1 Slotted element 1 Slotted element 2 Comment 2 Text node 2', + ); + }); +}); diff --git a/test/end-to-end/src/scoped-hydration/shadow-wrapper.tsx b/test/end-to-end/src/scoped-hydration/shadow-wrapper.tsx new file mode 100644 index 00000000..6d1c6208 --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/shadow-wrapper.tsx @@ -0,0 +1,25 @@ +import { Component, Host, h } from '@rindo/core'; + +@Component({ + tag: 'shadow-wrapper', + shadow: true, + styles: ` + :host { + display: block; + border: 3px solid red; + } + `, +}) +export class Wrapper { + render() { + return ( + + Shadow Wrapper Start +

Shadow Slot before

+ Wrapper Slot Fallback +

Shadow Slot after

+ Shadow Wrapper End +
+ ); + } +} diff --git a/test/end-to-end/src/scoped-hydration/shadow.tsx b/test/end-to-end/src/scoped-hydration/shadow.tsx new file mode 100644 index 00000000..17857976 --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/shadow.tsx @@ -0,0 +1,27 @@ +import { Component, Host, h } from '@rindo/core'; + +@Component({ + tag: 'shadow-child', + shadow: true, + styles: ` + :host { + display: block; + border: 3px solid blue; + } + `, +}) +export class MyApp { + render() { + return ( + +
+ Nested Shadow Component Start +
+ +
Slotted fallback content
+
+ Nested Shadow Component End +
+ ); + } +} From e031f3ea40448727b0d860cbb043b09cdcc34593 Mon Sep 17 00:00:00 2001 From: NKDuy Date: Thu, 4 Sep 2025 21:29:35 +0700 Subject: [PATCH 7/9] fix(mock-doc): don't show error message for SSR workflows --- src/mock-doc/node.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/mock-doc/node.ts b/src/mock-doc/node.ts index da8f2ad8..01a14634 100644 --- a/src/mock-doc/node.ts +++ b/src/mock-doc/node.ts @@ -234,10 +234,15 @@ export class MockElement extends MockNode { attachInternals(): MockElementInternals { return new Proxy({} as unknown as MockElementInternals, { get: function (_target, prop, _receiver) { - console.error( - `NOTE: Property ${String(prop)} was accessed on ElementInternals, but this property is not implemented. -Testing components with ElementInternals is fully supported in e2e tests.`, - ); + /** + * only print warning when running in a test environment + */ + if ('process' in globalThis && globalThis.process.env.__RINDO_SPEC_TESTS__) { + console.error( + `NOTE: Property ${String(prop)} was accessed on ElementInternals, but this property is not implemented. + Testing components with ElementInternals is fully supported in e2e tests.`, + ); + } }, }); } From f180377ef35cdc7abc51c780159f474526f3305e Mon Sep 17 00:00:00 2001 From: NKDuy Date: Thu, 4 Sep 2025 21:45:48 +0700 Subject: [PATCH 8/9] chore: bump deps --- package-lock.json | 29 ++++++++++++++++------ package.json | 2 +- src/testing/jest/jest-29/package-lock.json | 9 ++++--- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 952ccaa0..d329b8e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@rollup/plugin-json": "6.1.0", "@rollup/plugin-node-resolve": "9.0.0", "@rollup/plugin-replace": "5.0.7", - "@rollup/pluginutils": "5.1.0", + "@rollup/pluginutils": "5.1.3", "@types/eslint": "^8.4.6", "@types/exit": "^0.1.31", "@types/fs-extra": "^11.0.0", @@ -2373,15 +2373,15 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", - "integrity": "sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.3.tgz", + "integrity": "sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==", "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", - "picomatch": "^2.3.1" + "picomatch": "^4.0.2" }, "engines": { "node": ">=14.0.0" @@ -2395,6 +2395,19 @@ } } }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@samverschueren/stream-to-observable": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz", @@ -5203,9 +5216,9 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "50.5.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.5.0.tgz", - "integrity": "sha512-xTkshfZrUbiSHXBwZ/9d5ulZ2OcHXxSvm/NPo494H/hadLRJwOq5PMV0EUpMqsb9V+kQo+9BAgi6Z7aJtdBp2A==", + "version": "50.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.6.0.tgz", + "integrity": "sha512-tCNp4fR79Le3dYTPB0dKEv7yFyvGkUCa+Z3yuTrrNGGOxBlXo9Pn0PEgroOZikUQOGjxoGMVKNjrOHcYEdfszg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/package.json b/package.json index b665cbd6..0e67bd29 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "@rollup/plugin-json": "6.1.0", "@rollup/plugin-node-resolve": "9.0.0", "@rollup/plugin-replace": "5.0.7", - "@rollup/pluginutils": "5.1.0", + "@rollup/pluginutils": "5.1.3", "@types/eslint": "^8.4.6", "@types/exit": "^0.1.31", "@types/fs-extra": "^11.0.0", diff --git a/src/testing/jest/jest-29/package-lock.json b/src/testing/jest/jest-29/package-lock.json index cd2677a3..abc7316b 100644 --- a/src/testing/jest/jest-29/package-lock.json +++ b/src/testing/jest/jest-29/package-lock.json @@ -1098,10 +1098,11 @@ } }, "node_modules/@types/jest": { - "version": "29.5.12", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", - "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, + "license": "MIT", "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" @@ -3628,4 +3629,4 @@ } } } -} \ No newline at end of file +} From 93905577b5d0fab4b103e96a828a7f5ee3ced700 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 22:02:22 +0700 Subject: [PATCH 9/9] v4.23.0 (#290) Co-authored-by: khanhduy1407 <68154054+khanhduy1407@users.noreply.github.com> --- CHANGELOG.md | 19 +++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff51e3e..2d1c60ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# 🧀 [4.23.0](https://github.com/rindojs/rindo/compare/v4.22.3...v4.23.0) (2025-09-04) + + +### Bug Fixes + +* `patchChildSlotNodes` & `scopedSlotTextContentFix` not being applied ([855717c](https://github.com/rindojs/rindo/commit/855717c0814c3b1b4f1a4e60046e44564c0b6da0)) +* change `hasHostListenerAttached` from var to protoype property ([b98e0bb](https://github.com/rindojs/rindo/commit/b98e0bbb9adb51bd5395f7c4064773a33ed0d07a)) +* **mock-doc:** don't show error message for SSR workflows ([e031f3e](https://github.com/rindojs/rindo/commit/e031f3ea40448727b0d860cbb043b09cdcc34593)) +* rewrite SSR client-side hydration ([9403593](https://github.com/rindojs/rindo/commit/9403593fd9431cfc3239f39ab098eb1b737f8a86)) +* **runtime:** ensure `Node` is defined ([8ca7e1d](https://github.com/rindojs/rindo/commit/8ca7e1d14923893ed83338b25f4e614859de0f6a)) +* stop `experimentalScopedSlotChanges` warning msg on startup ([e68ff08](https://github.com/rindojs/rindo/commit/e68ff0831d7b74cedfe06ddb95bbaff28cb4ee62)) + + +### Features + +* prop `get` `set` new ([5223cbf](https://github.com/rindojs/rindo/commit/5223cbfa61853ab2a45a7a89a591463c81787a95)) + + + ## 🌙 [4.22.3](https://github.com/rindojs/rindo/compare/v4.22.2...v4.22.3) (2025-01-31) diff --git a/package-lock.json b/package-lock.json index d329b8e7..1ac37539 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rindo/core", - "version": "4.22.3", + "version": "4.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rindo/core", - "version": "4.22.3", + "version": "4.23.0", "license": "MIT", "bin": { "rindo": "bin/rindo" diff --git a/package.json b/package.json index 0e67bd29..00a1710e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rindo/core", - "version": "4.22.3", + "version": "4.23.0", "license": "MIT", "main": "./internal/rindo-core/index.cjs", "module": "./internal/rindo-core/index.js",