diff --git a/docs-site/src/content/docs/guide/index.md b/docs-site/src/content/docs/guide/index.md index e757f61..5472f35 100644 --- a/docs-site/src/content/docs/guide/index.md +++ b/docs-site/src/content/docs/guide/index.md @@ -1,37 +1,37 @@ ---- -title: Guide -sidebar: - order: 4 ---- - -The Guide explains how Regor behaves in real runtime usage, with patterns that map directly to production code. - -## Guide Map - -1. [Reactivity](/guide/reactivity) - Core primitives (`ref`, `sref`, computed, observers), update semantics, and state design. - -2. [Templates and Expressions](/guide/templates) - Interpolation transform, parser/evaluator capabilities, and template authoring patterns. - -3. [Components](/guide/components) - Component creation, input channels (`:x`, `r-bind:x`, `:context`, `r-context`), slots, and inherit behavior. - -4. [TypeScript](/guide/typescript) - Strong typing patterns for app contexts, class-based contexts, and `ComponentHead`. - -5. [Lifecycle and Cleanup](/guide/lifecycle-and-cleanup) - Mounted/unmounted hooks, `unmount` vs `unbind`, and `drainUnbind` usage. - -6. [Mounting](/guide/mounting) - Static-first + dynamic islands and existing-markup binding patterns. - -7. [Persisted State](/guide/persisted-state) - `persist(...)` patterns for durable reactive state. - -8. [Performance](/guide/performance) - Benchmark methodology, bottleneck analysis, and optimization workflow. - -9. [Troubleshooting](/guide/troubleshooting) - Symptom -> cause -> fix guide for common runtime issues. - +--- +title: Guide +sidebar: + order: 4 +--- + +The Guide explains how Regor behaves in real runtime usage, with patterns that map directly to production code. + +## Guide Map + +1. [Reactivity](/guide/reactivity) + Core primitives (`ref`, `sref`, computed, observers), update semantics, and state design. + +2. [Templates and Expressions](/guide/templates) + Interpolation behavior, parser/evaluator capabilities, and template authoring patterns. + +3. [Components](/guide/components) + Component creation, input channels (`:x`, `r-bind:x`, `:context`, `r-context`), slots, and inherit behavior. + +4. [TypeScript](/guide/typescript) + Strong typing patterns for app contexts, class-based contexts, and `ComponentHead`. + +5. [Lifecycle and Cleanup](/guide/lifecycle-and-cleanup) + Mounted/unmounted hooks, `unmount` vs `unbind`, and `drainUnbind` usage. + +6. [Mounting](/guide/mounting) + Static-first + dynamic islands and existing-markup binding patterns. + +7. [Persisted State](/guide/persisted-state) + `persist(...)` patterns for durable reactive state. + +8. [Performance](/guide/performance) + Benchmark methodology, bottleneck analysis, and optimization workflow. + +9. [Troubleshooting](/guide/troubleshooting) + Symptom -> cause -> fix guide for common runtime issues. + diff --git a/docs-site/src/content/docs/guide/templates.md b/docs-site/src/content/docs/guide/templates.md index 420df1e..3ef2ff0 100644 --- a/docs-site/src/content/docs/guide/templates.md +++ b/docs-site/src/content/docs/guide/templates.md @@ -29,14 +29,13 @@ Regor supports two interpolation delimiters:

[[ user.name ]]

``` -## Interpolation Transform Rules +## Interpolation Behavior When interpolation is enabled (`RegorConfig.useInterpolation = true`): -1. Interpolation in text nodes is converted to directive bindings before normal bind phase. -2. If an element contains exactly one interpolation token with only surrounding whitespace, Regor rewrites that element to use `r-text`. -3. Mixed text + interpolation is split into text nodes and generated `` nodes. -4. Subtrees marked with `r-pre` are skipped. +1. Interpolation expressions in text nodes become reactive text. +2. Interpolation works as the whole text content or mixed with surrounding text. +3. Subtrees marked with `r-pre` are skipped. If `useInterpolation = false`, `{{ ... }}` and `[[ ... ]]` stay as plain text. diff --git a/package.json b/package.json index f3900e5..fe97ec0 100644 --- a/package.json +++ b/package.json @@ -1,61 +1,61 @@ -{ - "name": "regor", - "version": "1.7.2", - "description": "A modern UI framework for web and desktop applications, inspired by Vue's concepts and powered by simplicity and flexibility.", - "author": "Ahmed Yasin Koculu", - "license": "MIT", - "type": "module", - "main": "dist/regor.es2022.esm.js", - "module": "dist/regor.es2022.esm.js", - "types": "dist/regor.d.ts", - "scripts": { - "build": "yarn tsx scripts/bundle.ts && yarn dts", - "bench:build": "yarn tsx benchmarks/build.ts", - "bench:serve": "yarn bench:build && yarn tsx benchmarks/server.ts", - "perf": "yarn tsx benchmarks/minidom/perf.ts", - "perf:record": "yarn tsx benchmarks/minidom/perf.ts record", - "perf:check": "yarn tsx benchmarks/minidom/perf.ts check", - "perf:profile": "yarn tsx benchmarks/minidom/profile.ts", - "dts": "yarn dts-bundle-generator src/index.ts -o dist/regor.d.ts && yarn tsx scripts/append-overloads.ts", - "lint": "yarn eslint src/**/*.ts tests/**/*.ts", - "watch": "yarn tsx scripts/bundle.ts --watch", - "format": "yarn prettier --write \"**/*.ts\"", - "test": "vitest", - "coverage": "yarn vitest run --coverage --coverage.include='src/**/*.ts' --coverage.reporter=text-summary --coverage.reporter=text" - }, - "files": [ - "dist" - ], - "sideEffects": false, - "repository": { - "type": "git", - "url": "git+https://github.com/koculu/regor.git" - }, - "keywords": [ - "regor" - ], - "bugs": { - "url": "https://github.com/koculu/regor/issues" - }, - "homepage": "https://github.com/koculu/regor#readme", - "devDependencies": { - "@eslint/js": "^9.39.2", - "@stylistic/eslint-plugin": "^5.7.1", - "@types/node": "^25.2.1", - "@vitest/coverage-istanbul": "3.2.3", - "dts-bundle-generator": "^9.5.1", - "esbuild": "^0.25.5", - "eslint": "^9.39.2", - "eslint-plugin-simple-import-sort": "^12.1.1", - "eslint-plugin-tsdoc": "^0.5.0", - "prettier": "^3.5.3", - "tsx": "^4.21.0", - "typescript": "^5.9.3", - "typescript-eslint": "^8.54.0", - "vitest": "^3.2.3" - }, - "packageManager": "yarn@4.9.2", - "resolutions": { - "typescript": "^5.9.3" - } -} +{ + "name": "regor", + "version": "1.7.3", + "description": "A modern UI framework for web and desktop applications, inspired by Vue's concepts and powered by simplicity and flexibility.", + "author": "Ahmed Yasin Koculu", + "license": "MIT", + "type": "module", + "main": "dist/regor.es2022.esm.js", + "module": "dist/regor.es2022.esm.js", + "types": "dist/regor.d.ts", + "scripts": { + "build": "yarn tsx scripts/bundle.ts && yarn dts", + "bench:build": "yarn tsx benchmarks/build.ts", + "bench:serve": "yarn bench:build && yarn tsx benchmarks/server.ts", + "perf": "yarn tsx benchmarks/minidom/perf.ts", + "perf:record": "yarn tsx benchmarks/minidom/perf.ts record", + "perf:check": "yarn tsx benchmarks/minidom/perf.ts check", + "perf:profile": "yarn tsx benchmarks/minidom/profile.ts", + "dts": "yarn dts-bundle-generator src/index.ts -o dist/regor.d.ts && yarn tsx scripts/append-overloads.ts", + "lint": "yarn eslint src/**/*.ts tests/**/*.ts", + "watch": "yarn tsx scripts/bundle.ts --watch", + "format": "yarn prettier --write \"**/*.ts\"", + "test": "vitest", + "coverage": "yarn vitest run --coverage --coverage.include='src/**/*.ts' --coverage.reporter=text-summary --coverage.reporter=text" + }, + "files": [ + "dist" + ], + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/koculu/regor.git" + }, + "keywords": [ + "regor" + ], + "bugs": { + "url": "https://github.com/koculu/regor/issues" + }, + "homepage": "https://github.com/koculu/regor#readme", + "devDependencies": { + "@eslint/js": "^9.39.2", + "@stylistic/eslint-plugin": "^5.7.1", + "@types/node": "^25.2.1", + "@vitest/coverage-istanbul": "3.2.3", + "dts-bundle-generator": "^9.5.1", + "esbuild": "^0.25.5", + "eslint": "^9.39.2", + "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-tsdoc": "^0.5.0", + "prettier": "^3.5.3", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0", + "vitest": "^3.2.3" + }, + "packageManager": "yarn@4.9.2", + "resolutions": { + "typescript": "^5.9.3" + } +} diff --git a/src/bind/interpolation.ts b/src/bind/interpolation.ts index 956e3c8..0dfc3af 100644 --- a/src/bind/interpolation.ts +++ b/src/bind/interpolation.ts @@ -1,6 +1,7 @@ import { RegorConfig } from '../app/RegorConfig' import { getChildNodes } from '../common/common' import { isNullOrWhitespace } from '../common/is-what' +import { rgi } from './rgi' /** * @internal @@ -62,6 +63,7 @@ const interpolateTextNode = ( const delimiter = getInterpolationDelimiter(part, delimiters) if (delimiter) { const spanTag = document.createElement('span') + spanTag.setAttribute(rgi, '') spanTag.setAttribute( textDirective, part.substring( diff --git a/src/bind/rgi.ts b/src/bind/rgi.ts new file mode 100644 index 0000000..4e0c876 --- /dev/null +++ b/src/bind/rgi.ts @@ -0,0 +1,4 @@ +/** + * @internal + */ +export const rgi = 'r-rgi' diff --git a/src/directives/text.ts b/src/directives/text.ts index 1a08bab..c7cd100 100644 --- a/src/directives/text.ts +++ b/src/directives/text.ts @@ -1,11 +1,15 @@ -import { type Directive } from '../api/types' +import { type BindData, type Directive } from '../api/types' +import { rgi } from '../bind/rgi' +import { bindDataSymbol } from '../cleanup/bindDataSymbol' import { isMap, isObject, isSet } from '../common/is-what' import { flatten } from '../misc/flatten' +type BindableNode = { [bindDataSymbol]?: BindData } + /** * @internal */ -const updateText = (el: HTMLElement, values: unknown[]): void => { +const updateText = (el: Node, values: unknown[]): void => { const value = values[0] // https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#differences_from_innertext // Note: order is important: [isSet,isMap] should come before [isObject]. @@ -18,10 +22,32 @@ const updateText = (el: HTMLElement, values: unknown[]): void => { : (value?.toString() ?? '') } +const moveBindData = (from: Node, to: Node): void => { + const bindableFrom = from as BindableNode + const bindData = bindableFrom[bindDataSymbol] + if (!bindData) return + ;(to as BindableNode)[bindDataSymbol] = bindData + bindableFrom[bindDataSymbol] = undefined +} + +const createInterpolationTextNode = (el: HTMLElement): Node => { + if (el.tagName !== 'SPAN') return el + if (!el.hasAttribute(rgi)) return el + const parent = el.parentNode + if (!parent) return el + const textNode = el.ownerDocument.createTextNode('') + moveBindData(el, textNode) + parent.replaceChild(textNode, el) + return textNode +} + export const textDirective: Directive = { - mount: () => ({ - update: ({ el, values }) => { - updateText(el, values) - }, - }), + mount: ({ el }) => { + const target = createInterpolationTextNode(el) + return { + update: ({ values }) => { + updateText(target, values) + }, + } + }, } diff --git a/tests/app/createApp.spec.ts b/tests/app/createApp.spec.ts index d137822..7766c5e 100644 --- a/tests/app/createApp.spec.ts +++ b/tests/app/createApp.spec.ts @@ -31,15 +31,16 @@ test('click counter', () => { `, }, ) - expect(root.querySelector('div > span')?.textContent).toBe('0') + expect(root.querySelector('div')?.textContent).toBe('count: 0') for (let i = 0; i < 10; ++i) { root.querySelector('button')?.click() - expect(root.querySelector('div > span')?.textContent).toBe(`${i + 1}`) + expect(root.querySelector('div')?.textContent).toBe(`count: ${i + 1}`) } htmlEqual( root.innerHTML, - raw`
count: 10
`, + raw`
count: 10
`, ) + expect(root.querySelector('div > span')).toBeNull() }) test('interpolation supports bracket syntax', () => { @@ -73,6 +74,44 @@ test('interpolation supports both syntaxes at once', () => { expect(root.textContent).toBe('hello world') }) +test('interpolation removes only generated spans and keeps cleanup anchored', () => { + const root = document.createElement('div') + const message = ref('hello') + const title = ref('title') + const app = createApp( + { + message, + title, + }, + { + element: root, + template: html`
+

Hello {{ message }}!

+ +
`, + }, + ) + + const paragraph = root.querySelector('p') as HTMLParagraphElement + const manual = root.querySelector('#manual') as HTMLSpanElement + + expect(paragraph.textContent).toBe('Hello hello!') + expect(paragraph.querySelector('span')).toBeNull() + expect(manual.tagName).toBe('SPAN') + expect(manual.textContent).toBe('title') + + message('world') + title('next') + expect(paragraph.textContent).toBe('Hello world!') + expect(manual.textContent).toBe('next') + + app.unbind() + message('stopped') + title('stopped') + expect(paragraph.textContent).toBe('Hello world!') + expect(manual.textContent).toBe('next') +}) + test('createApp mounts json template and supports unbind', () => { const root = document.createElement('div') root.appendChild(document.createElement('span')) diff --git a/tests/app/createComponent.spec.ts b/tests/app/createComponent.spec.ts index ecdf624..c2353f3 100644 --- a/tests/app/createComponent.spec.ts +++ b/tests/app/createComponent.spec.ts @@ -586,20 +586,20 @@ test('should render nested component with reactive properties', () => { root.innerHTML, html`
- name: root + name: root
- name: child 1 + name: child 1
- name: child 2 + name: child 2
- name: child 3 + name: child 3
@@ -616,20 +616,20 @@ test('should render nested component with reactive properties', () => { root.innerHTML, html`
- name: new root + name: new root
- name: new child 1 + name: new child 1
- name: new child 2 + name: new child 2
- name: new child 3 + name: new child 3
@@ -1488,6 +1488,38 @@ test('enableSwitch controls whether slot bindings use parent or component contex ) }) +test('slotted interpolation removes generated spans and stays reactive', () => { + const root = document.createElement('div') + const message = ref('hello') + const shell = defineComponent(html`
`, { + context: (head) => { + head.enableSwitch = true + return {} + }, + }) + + createApp( + { + components: { shell }, + message, + }, + { + element: root, + template: html` +

slot says {{ message }}

+
`, + }, + ) + + const copy = root.querySelector('.slot-copy') as HTMLParagraphElement + expect(copy.textContent).toBe('slot says hello') + expect(copy.querySelector('span')).toBeNull() + + message('updated') + expect(copy.textContent).toBe('slot says updated') + expect(copy.querySelector('span')).toBeNull() +}) + test('defineComponent supports disabling interpolation and direct element children scan', () => { const rootEl = document.createElement('div') rootEl.innerHTML = '

{{ msg }}

' diff --git a/tests/directives/r-for.spec.ts b/tests/directives/r-for.spec.ts index a291b77..c2921cb 100644 --- a/tests/directives/r-for.spec.ts +++ b/tests/directives/r-for.spec.ts @@ -28,17 +28,17 @@ test('should mount the people into reactive divs.', () => { ) const people = app.context.people() const testContent = () => { - expect( - [...root.querySelectorAll('[class]')].map((x) => x.className), - ).toStrictEqual(people.map((x) => x().name())) - - expect( - [...root.querySelectorAll('span')].map((x) => x.textContent), - ).toStrictEqual( - people - .map((x) => x()) - .flatMap((x, i) => [i.toString(), x.name(), x.age().toString()]), + const rows = [...root.querySelectorAll('[class]')] + expect(rows.map((x) => x.className)).toStrictEqual( + people.map((x) => x().name()), ) + expect(rows.map((x) => x.textContent?.replace(/\s+/g, ' ').trim())) + .toStrictEqual( + people.map((x, i) => { + const person = x() + return `${i} - name: ${person.name()} age: ${person.age()}` + }), + ) } testContent() people[0]().name.value = 'Ali' @@ -120,23 +120,22 @@ test('should mount nested r-for.', () => { const people = app.context.people() const duplicate = app.context.duplicate const testContent = () => { - expect( - [...root.querySelectorAll('[class]')].map((x) => x.className), - ).toStrictEqual( + const rows = [...root.querySelectorAll('[class]')] + expect(rows.map((x) => x.className)).toStrictEqual( duplicateArray( duplicate(), people.map((x) => x().name()), ), ) - expect( - [...root.querySelectorAll('span')].map((x) => x.textContent), - ).toStrictEqual( + expect(rows.map((x) => x.textContent?.replace(/\s+/g, ' ').trim())) + .toStrictEqual( duplicateArray( duplicate(), - people - .map((x) => x()) - .flatMap((x, i) => [i.toString(), x.name(), x.age().toString()]), + people.map((x, i) => { + const person = x() + return `${i} - name: ${person.name()} age: ${person.age()}` + }), ), ) } @@ -262,8 +261,10 @@ test('should support index variable with parentheses', () => { }, ) expect( - [...root.querySelectorAll('span')].map((x) => x.textContent), - ).toStrictEqual(['0', 'Apple', '1', 'Banana']) + [...root.querySelectorAll('div')].map((x) => + x.textContent?.replace(/\s+/g, ' ').trim(), + ), + ).toStrictEqual(['0 - Apple', '1 - Banana']) }) test('should iterate object properties', () => { @@ -280,8 +281,10 @@ test('should iterate object properties', () => { }, ) expect( - [...root.querySelectorAll('span')].map((x) => x.textContent), - ).toStrictEqual(['name', 'Alice', 'age', '25']) + [...root.querySelectorAll('div')].map((x) => + x.textContent?.replace(/\s+/g, ' ').trim(), + ), + ).toStrictEqual(['name: Alice', 'age: 25']) }) test('should support object destructuring with index', () => { @@ -301,8 +304,10 @@ test('should support object destructuring with index', () => { }, ) expect( - [...root.querySelectorAll('span')].map((x) => x.textContent), - ).toStrictEqual(['0', 'Alice', '25', '1', 'Bob', '30']) + [...root.querySelectorAll('div')].map((x) => + x.textContent?.replace(/\s+/g, ' ').trim(), + ), + ).toStrictEqual(['0 - Alice - 25', '1 - Bob - 30']) }) test('should handle expressions with spaces', () => { diff --git a/tests/minidom/minidom.test.ts b/tests/minidom/minidom.test.ts index 5a1f019..73671d5 100644 --- a/tests/minidom/minidom.test.ts +++ b/tests/minidom/minidom.test.ts @@ -175,16 +175,22 @@ describe('minidom parsing and serialization', () => { }, )) - it('supports replaceWith and replaceChildren', () => + it('supports replaceChild, replaceWith and replaceChildren', () => withDom( '
', ({ document }) => { const old = document.querySelector('#old') + const replacement = document.createElement('strong') + replacement.setAttribute('id', 'replacement') + old?.parentNode?.replaceChild(replacement, old) + expect(document.querySelector('div')?.firstChild).toBe(replacement) + expect(old?.parentNode).toBeNull() + const first = document.createElement('b') first.setAttribute('id', 'new-a') const second = document.createElement('b') second.setAttribute('id', 'new-b') - old?.replaceWith(first, second) + replacement.replaceWith(first, second) const div = document.querySelector('div') expect( [...(div?.querySelectorAll('b') ?? [])].map((el) => diff --git a/tests/minidom/minidom.ts b/tests/minidom/minidom.ts index f4f6667..c82c554 100644 --- a/tests/minidom/minidom.ts +++ b/tests/minidom/minidom.ts @@ -176,6 +176,17 @@ class MiniNode { return insertNode(this, node, ref) } + replaceChild(node: MiniNode, oldNode: MiniNode): MiniNode { + if (node === oldNode) return oldNode + const index = this.childNodes.indexOf(oldNode) + if (index === -1) { + throw new Error('Reference node is not a child of this parent') + } + insertNode(this, node, oldNode) + this.removeChild(oldNode) + return oldNode + } + removeChild(node: MiniNode) { const index = this.childNodes.indexOf(node) if (index === -1) return node