From c9cc7503743e156c93906e9ec97aa9815b79f19f Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Wed, 14 Oct 2026 05:24:58 +0200 Subject: [PATCH 1/3] Prepare context for apps using dom without template. --- docs-site/design/topBar.ts | 10 ++++- package.json | 2 +- src/app/createApp.ts | 2 + tests/app/existing-dom-context-repro.spec.ts | 45 ++++++++++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 tests/app/existing-dom-context-repro.spec.ts diff --git a/docs-site/design/topBar.ts b/docs-site/design/topBar.ts index fc4d018..57e110a 100644 --- a/docs-site/design/topBar.ts +++ b/docs-site/design/topBar.ts @@ -37,8 +37,14 @@ const topBarTemplate = html` - - + + + {{ pageContext.resolvePublicHref('/') }} + {{ resolvePublicHref('/') }} + {{ pageContext.site.logo.icon }} + {{ site.logo.icon }} + {{ pageContext.pageInfo.frontmatter }} + {{ pageInfo.frontmatter }} + +` + +test.each(['template', 'existing DOM'] as const)( + 'parent :context supplies fields when mounting %s', + async (mode) => { + const root = document.createElement('div') + const pageContext = { + resolvePublicHref: (path: string) => path, + site: { logo: { icon: 'tabler:binary-tree' } }, + pageInfo: { frontmatter: { title: 'Dashboard' } }, + } + // PureStack's renderApp parses the HTML first, then passes only element. + if (mode === 'existing DOM') root.innerHTML = template + const app = createApp( + { pageContext }, + mode === 'template' ? { element: root, template } : { element: root }, + ) + try { + const read = (selector: string) => + [...root.querySelectorAll(selector)].map( + (element) => element.textContent, + ) + const expected = ['/', 'tabler:binary-tree', '{"title":"Dashboard"}'] + + expect(read('.qualified')).toEqual(expected) + expect(read('.scoped')).toEqual(expected) + } finally { + app.unbind() + await drainUnbind() + } + }, +) From d981a6562ca8285558242fd721a600bdcaef1bbc Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Wed, 14 Oct 2026 06:10:21 +0200 Subject: [PATCH 2/3] Fix edge cases for context binding. --- src/app/createApp.ts | 2 +- src/app/prepareContext.ts | 4 + src/bind/ComponentBinder.ts | 2 + src/bind/DynamicBinder.ts | 64 +++++++++--- src/bind/IfBinder.ts | 5 +- tests/app/context-edge-repro.spec.ts | 150 +++++++++++++++++++++++++++ 6 files changed, 210 insertions(+), 17 deletions(-) create mode 100644 tests/app/context-edge-repro.spec.ts diff --git a/src/app/createApp.ts b/src/app/createApp.ts index faf48d1..5ecc085 100644 --- a/src/app/createApp.ts +++ b/src/app/createApp.ts @@ -141,6 +141,6 @@ class RegorApp { } __bind(): void { - this.__binder.__bindDefault(this.__root) + this.__binder.__dynamicBinder.__bindRoot(this.__root) } } diff --git a/src/app/prepareContext.ts b/src/app/prepareContext.ts index f14f296..0f33029 100644 --- a/src/app/prepareContext.ts +++ b/src/app/prepareContext.ts @@ -10,6 +10,10 @@ export const prepareContext = ( const root = isTemplate(template) ? template.content : template const selector = `${toSelector(names.context)},${toSelector(names.contextAlias)},template` for (const element of root.querySelectorAll?.(selector) ?? []) { + let ancestor: Element | null = element + while (ancestor && !ancestor.hasAttribute(names.pre)) + ancestor = ancestor.parentElement + if (ancestor) continue if (isTemplate(element)) { prepareContext(element.content, config) if ( diff --git a/src/bind/ComponentBinder.ts b/src/bind/ComponentBinder.ts index 68d846d..985f68f 100644 --- a/src/bind/ComponentBinder.ts +++ b/src/bind/ComponentBinder.ts @@ -508,6 +508,7 @@ export class ComponentBinder { const stack = this.__getChildElements(root).reverse() while (stack.length > 0) { const current = stack.pop() as HTMLElement + if (current.hasAttribute(this.__binder.__pre)) continue if (current.matches(selector)) { result.push(current) continue @@ -527,6 +528,7 @@ export class ComponentBinder { const stack = this.__getChildElements(root).reverse() while (stack.length > 0) { const current = stack.pop() as HTMLElement + if (current.hasAttribute(binder.__pre)) continue action(current) if (!isNullOrWhitespace(selector) && current.matches(selector)) continue if (current.hasAttribute(isName) || current.hasAttribute('is')) continue diff --git a/src/bind/DynamicBinder.ts b/src/bind/DynamicBinder.ts index d315ff6..baacf83 100644 --- a/src/bind/DynamicBinder.ts +++ b/src/bind/DynamicBinder.ts @@ -10,9 +10,10 @@ import { setSwitchOwner } from './switch' const noopStopObserving: StopObserving = () => {} const mount = (nodes: ChildNode[], parent: Element): void => { + const target = isTemplate(parent) ? parent.content : parent for (const x of nodes) { const node = x.cloneNode(true) - parent.appendChild(node) + target.appendChild(node) } } @@ -29,6 +30,50 @@ export class DynamicBinder { this.__is = binder.__config.__builtInNames.is } + __isContextHost(element: Element): boolean { + return ( + element.hasAttribute('_rcontext') && + !this.__binder.__componentBinder.__isComponentHost(element) + ) + } + + __readContext(element: Element): unknown { + const names = this.__binder.__config.__builtInNames + const expression = + element.getAttribute(names.context) ?? + element.getAttribute(names.contextAlias) + const context = + expression === null + ? undefined + : this.__binder.__parser + .__parse(expression, undefined, undefined, true, true) + .value()[0] + element.removeAttribute(names.context) + element.removeAttribute(names.contextAlias) + element.removeAttribute('_rcontext') + return context + } + + __bindRoot(element: Element): void { + const binder = this.__binder + const names = binder.__config.__builtInNames + if ( + element.hasAttribute(binder.__pre) || + element.hasAttribute(this.__is) || + element.hasAttribute('is') || + (!element.hasAttribute(names.context) && + !element.hasAttribute(names.contextAlias)) || + binder.__componentBinder.__isComponentHost(element) + ) { + binder.__bindDefault(element) + return + } + const context = this.__readContext(element) + // This parser belongs to the app, so the root scope is its base context. + if (isObject(context)) binder.__parser.__push(context) + binder.__bindDefault(element) + } + __bindAll(element: Element): boolean { const isComponentElement = element.hasAttribute(this.__is) if (isComponentElement || element.hasAttribute('is')) @@ -129,20 +174,9 @@ export class DynamicBinder { const value = parseResult.value const parser = this.__binder.__parser const capturedContext = parser.__capture() - const names = this.__binder.__config.__builtInNames - let context: unknown - if (el.hasAttribute('_rcontext')) { - const contextExpression = - el.getAttribute(names.context) ?? el.getAttribute(names.contextAlias) - if (contextExpression !== null) { - context = parser - .__parse(contextExpression, undefined, undefined, true, true) - .value()[0] - } - el.removeAttribute(names.context) - el.removeAttribute(names.contextAlias) - el.removeAttribute('_rcontext') - } + const context = el.hasAttribute('_rcontext') + ? this.__readContext(el) + : undefined const mounted = { name: '' } diff --git a/src/bind/IfBinder.ts b/src/bind/IfBinder.ts index 6dbbf6a..28e08b8 100644 --- a/src/bind/IfBinder.ts +++ b/src/bind/IfBinder.ts @@ -80,7 +80,9 @@ export class IfBinder { } __bindAll(element: Element): boolean { - const isIfElement = element.hasAttribute(this.__if) + const isIfElement = + element.hasAttribute(this.__if) && + !this.__binder.__dynamicBinder.__isContextHost(element) if (isIfElement) this.__bind(element as HTMLElement) const isDynamicElement = element.hasAttribute(this.__binder.__config.__builtInNames.is) || @@ -109,6 +111,7 @@ export class IfBinder { __bind(el: HTMLElement): void { if ( + this.__binder.__dynamicBinder.__isContextHost(el) || el.hasAttribute(this.__pre) || this.__isProcessedOrMark(el) || // if elements should only be mounted after for's are mounted. diff --git a/tests/app/context-edge-repro.spec.ts b/tests/app/context-edge-repro.spec.ts new file mode 100644 index 0000000..2eb0adc --- /dev/null +++ b/tests/app/context-edge-repro.spec.ts @@ -0,0 +1,150 @@ +import { expect, test, vi } from 'vitest' + +import { createApp, defineComponent, drainUnbind, ref, sref } from '../../src' + +test.each([':context', 'r-context'])( + '%s on the mount root preserves root identity and cleanup', + async (attribute) => { + const container = document.createElement('div') + const root = document.createElement('aside') + root.setAttribute(attribute, 'user') + root.setAttribute(':title', 'name') + root.innerHTML = '' + container.appendChild(root) + const name = ref('Ada') + const context = { user: { name }, name: 'outer' } + const app = createApp(context, { element: root }) + try { + expect(root.querySelector('span')?.textContent).toBe('Ada') + expect(root.getAttribute('title')).toBe('Ada') + expect(container.firstChild).toBe(root) + expect(app.context).toBe(context) + expect(context.name).toBe('outer') + name('Grace') + expect(root.querySelector('span')?.textContent).toBe('Grace') + expect(root.getAttribute('title')).toBe('Grace') + app.unbind() + name('After unbind') + expect(root.querySelector('span')?.textContent).toBe('Grace') + expect(root.getAttribute('title')).toBe('Grace') + app.unmount() + expect(container.firstChild).toBeNull() + } finally { + app.unbind() + await drainUnbind() + } + }, +) + +test('repro: template context preserves and binds its content with browser template semantics', async () => { + const originalCreateElement = document.createElement.bind(document) + const createElement = vi + .spyOn(document, 'createElement') + .mockImplementation((tag, options) => { + const element = originalCreateElement(tag, options) + if ( + element instanceof HTMLTemplateElement && + element.childNodes === element.content.childNodes + ) { + // MiniDOM aliases these lists. Browsers keep template content separate. + Object.defineProperty(element, 'childNodes', { + value: [], + writable: true, + }) + } + return element + }) + const root = document.createElement('div') + const app = createApp( + { user: { name: 'Ada' } }, + { + element: root, + template: + '', + }, + ) + try { + expect(root.querySelector('span')?.textContent).toBe('Ada') + expect(root.querySelector('template')).toBeNull() + } finally { + app.unbind() + createElement.mockRestore() + await drainUnbind() + } +}) + +test('repro: r-pre leaves nested context hosts untouched', async () => { + const root = document.createElement('div') + root.innerHTML = + '
' + const originalHtml = root.innerHTML + const app = createApp( + { + user: { name: 'Ada' }, + components: { card: defineComponent('bound') }, + }, + { element: root }, + ) + try { + expect(root.innerHTML).toBe(originalHtml) + } finally { + app.unbind() + await drainUnbind() + } +}) + +test('r-if on a context host reacts to that context without leaking to siblings', async () => { + const root = document.createElement('div') + const visible = ref(false) + const name = ref('Ada') + const app = createApp( + { user: { visible, name }, visible: true, name: 'outer' }, + { + element: root, + template: + '
', + }, + ) + try { + expect(root.querySelector('span')).toBeNull() + visible(true) + expect(root.querySelector('span')?.textContent).toBe('Ada') + expect(root.querySelector('b')?.textContent).toBe('outer') + name('Grace') + expect(root.querySelector('span')?.textContent).toBe('Grace') + visible(false) + expect(root.querySelector('span')).toBeNull() + visible(true) + expect(root.querySelector('span')?.textContent).toBe('Grace') + } finally { + app.unbind() + await drainUnbind() + } +}) + +test('row context works with r-for and a reactive r-if on the same host', async () => { + const root = document.createElement('div') + const ada = { name: 'Ada', visible: ref(true) } + const grace = { name: 'Grace', visible: ref(false) } + const rows = sref([ada, grace]) + const app = createApp( + { rows }, + { + element: root, + template: + '
', + }, + ) + try { + const names = () => + [...root.querySelectorAll('span')].map((element) => element.textContent) + expect(names()).toEqual(['Ada']) + grace.visible(true) + expect(names()).toEqual(['Ada', 'Grace']) + ada.visible(false) + expect(names()).toEqual(['Grace']) + } finally { + app.unbind() + await drainUnbind() + } +}) From 16fcebc21ff88311cd897e2405f038283ab4112e Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Wed, 14 Oct 2026 06:20:34 +0200 Subject: [PATCH 3/3] Add mixed runtime version guard. --- package.json | 6 +- src/index.ts | 2 + src/runtime.ts | 21 ++++++ tests/runtime.spec.ts | 144 ++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/runtime.ts create mode 100644 tests/runtime.spec.ts diff --git a/package.json b/package.json index 614ba9b..4dfefeb 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,11 @@ "files": [ "dist" ], - "sideEffects": false, + "sideEffects": [ + "./src/index.ts", + "./src/runtime.ts", + "./dist/*.js" + ], "repository": { "type": "git", "url": "git+https://github.com/PureStackStudio/Regor.git" diff --git a/src/index.ts b/src/index.ts index 9d986b8..660537e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import './runtime' + export type { AnyRef, BindData, diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..67772a9 --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,21 @@ +import { version } from '../package.json' +import { srefSymbol } from './reactivity/refSymbols' + +const runtimeKey = Symbol.for('regor.runtime') +const runtimeGlobal = globalThis as typeof globalThis & { + [runtimeKey]?: { version: string; identity: symbol } +} +const loaded = runtimeGlobal[runtimeKey] + +if (loaded && loaded.identity !== srefSymbol) { + const error = new Error( + `Regor ${loaded.version} is already loaded; another runtime using ${version} was imported. ` + + 'Ensure the renderer and components share one Regor instance.', + ) + error.name = 'RegorRuntimeMismatch' + throw error +} + +if (!loaded) { + runtimeGlobal[runtimeKey] = { version, identity: srefSymbol } +} diff --git a/tests/runtime.spec.ts b/tests/runtime.spec.ts new file mode 100644 index 0000000..d6ecfcc --- /dev/null +++ b/tests/runtime.spec.ts @@ -0,0 +1,144 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createContext, runInContext } from 'node:vm' + +import { build, type Format } from 'esbuild' +import { afterAll, beforeAll, expect, test } from 'vitest' + +import packageJson from '../package.json' + +const entry = fileURLToPath(new URL('../src/index.ts', import.meta.url)) +const variants = [ + { format: 'iife', minify: false }, + { format: 'iife', minify: true }, + { format: 'cjs', minify: false }, + { format: 'cjs', minify: true }, +] as const +const bundles: string[] = [] +let otherVersion: string +let fixture: string + +const bundle = async ( + format: Format, + minify = false, + version = packageJson.version, +): Promise => { + const result = await build({ + entryPoints: [entry], + bundle: true, + write: false, + platform: 'browser', + target: 'es2015', + format, + globalName: 'Regor', + minify, + mangleProps: minify ? /__.*?$/ : undefined, + plugins: [ + { + name: 'runtime-version-fixture', + setup(builder) { + builder.onLoad({ filter: /[/\\]package\.json$/ }, () => ({ + contents: JSON.stringify({ version }), + loader: 'json', + })) + }, + }, + ], + }) + const code = result.outputFiles[0].text + return format === 'cjs' + ? `(function () { const module = { exports: {} }; ${code}\nreturn module.exports })()` + : code +} + +beforeAll(async () => { + bundles.push( + ...(await Promise.all(variants.map((x) => bundle(x.format, x.minify)))), + ) + otherVersion = await bundle('iife', true, '9.9.9') + const fixtureRoot = fileURLToPath(new URL('../.tmp/', import.meta.url)) + await mkdir(fixtureRoot, { recursive: true }) + fixture = await mkdtemp(join(fixtureRoot, 'regor-runtime-')) +}) + +afterAll(async () => { + if (fixture) await rm(fixture, { recursive: true, force: true }) +}) + +test('repeated imports use the same runtime', async () => { + const first = await import('../src') + const second = await import('../src') + expect(second).toBe(first) + expect(second.isRef(first.ref('value'))).toBe(true) +}) + +test.each(variants)('rejects a second $format runtime, minify=$minify', (x) => { + const context = createContext({}) + runInContext(bundles[0], context) + expect(runInContext('Regor.sref("value")()', context)).toBe('value') + const registered = runInContext( + 'globalThis[Symbol.for("regor.runtime")]', + context, + ) + + expect(() => + runInContext(bundles[variants.indexOf(x)], context), + ).toThrowError( + expect.objectContaining({ + name: 'RegorRuntimeMismatch', + message: `Regor ${packageJson.version} is already loaded; another runtime using ${packageJson.version} was imported. Ensure the renderer and components share one Regor instance.`, + }), + ) + expect(runInContext('globalThis[Symbol.for("regor.runtime")]', context)).toBe( + registered, + ) +}) + +test.each([false, true])( + 'rejects different versions, reversed=%s', + (reversed) => { + const context = createContext({}) + const codes = reversed + ? [otherVersion, bundles[0]] + : [bundles[0], otherVersion] + const versions = reversed + ? ['9.9.9', packageJson.version] + : [packageJson.version, '9.9.9'] + runInContext(codes[0], context) + expect(() => runInContext(codes[1], context)).toThrowError( + expect.objectContaining({ + name: 'RegorRuntimeMismatch', + message: `Regor ${versions[0]} is already loaded; another runtime using ${versions[1]} was imported. Ensure the renderer and components share one Regor instance.`, + }), + ) + }, +) + +test('retains the guard when a consumer imports only html from the package', async () => { + const directory = join(fixture, 'node_modules', 'regor') + const dist = join(directory, 'dist') + await mkdir(dist, { recursive: true }) + await writeFile(join(directory, 'package.json'), JSON.stringify(packageJson)) + await writeFile(join(directory, packageJson.main), await bundle('esm', true)) + + const result = await build({ + stdin: { + contents: 'import { html } from "regor"; globalThis.result = html`hello`', + resolveDir: fixture, + }, + bundle: true, + write: false, + platform: 'browser', + format: 'iife', + minify: true, + }) + const code = result.outputFiles[0].text + const context = createContext({}) + runInContext(code, context) + expect(context.result).toBe('hello') + expect( + runInContext('globalThis[Symbol.for("regor.runtime")].version', context), + ).toBe(packageJson.version) + expect(() => runInContext(code, context)).toThrowError(/already loaded/) +}) diff --git a/tsconfig.json b/tsconfig.json index 9a4621d..ab46def 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "Bundler", + "resolveJsonModule": true, "noImplicitAny": true, "removeComments": false, "sourceMap": false,