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`
-
-
+
+
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/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/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()
+ }
+})
diff --git a/tests/app/existing-dom-context-repro.spec.ts b/tests/app/existing-dom-context-repro.spec.ts
new file mode 100644
index 0000000..68f2efe
--- /dev/null
+++ b/tests/app/existing-dom-context-repro.spec.ts
@@ -0,0 +1,45 @@
+import { expect, test } from 'vitest'
+
+import { createApp, drainUnbind } from '../../src'
+
+const template = `
+
+`
+
+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()
+ }
+ },
+)
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,