Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure plugins not reapplied #691

Merged
merged 9 commits into from
Feb 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ Each tagged version of Datastar is accompanied by a release note. Read the [rele
- Updated Idiomorph to version [0.7.2](https://github.com/bigskysoftware/idiomorph/blob/main/CHANGELOG.md#072---2025-02-20).
- When using `data-bind` on an element, the signal value now defaults to the element’s `value` attribute, provided the signal has not already been defined ([#685](https://github.com/starfederation/datastar/issues/685)).
- Whitespace is now maintained in merged fragments ([#658](https://github.com/starfederation/datastar/issues/658)).
- Attribute plugins now define a hash of their contents, preventing duplicate applies ([#691](https://github.com/starfederation/datastar/issues/691)).
- Attribute plugins are now applied to the `html` element instead of the `body` element ([#691](https://github.com/starfederation/datastar/issues/691)).

### Fixed

- Fixed a bug in which `datastar-remove-fragments` events were not having any effect ([#664](https://github.com/starfederation/datastar/issues/664)).
- Fixed a bug in which `datastarNaN` could be used as an auto-generated element ID ([#679](https://github.com/starfederation/datastar/issues/679)).
- Fixed a bug in which plugins were being applied to the DOM twice on page load.
- Fixed a bug in which `datastarNaN` could be used as an auto-generated element ID ([#679](https://github.com/starfederation/datastar/issues/679)).
20 changes: 10 additions & 10 deletions bundles/datastar-aliased.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions bundles/datastar-aliased.js.map

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions bundles/datastar-core.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions bundles/datastar-core.js.map

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions bundles/datastar.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions bundles/datastar.js.map

Large diffs are not rendered by default.

143 changes: 60 additions & 83 deletions library/src/engine/engine.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Hash, elUniqId, walkDOM } from '../utils/dom'
import { Hash, attrHash, elUniqId, walkDOM } from '../utils/dom'
import { camel } from '../utils/text'
import { debounce } from '../utils/timing'
import { effect } from '../vendored/preact-core'
import { DSP, DSS } from './consts'
import { initErr, runtimeErr } from './errors'
Expand All @@ -21,8 +20,6 @@ import {
type WatcherPlugin,
} from './types'

const removalKey = (k: string, v: string) => `${k}${DSP}${v}`

export class Engine {
aliasPrefix = ''
#signals: SignalsRoot = new SignalsRoot()
Expand All @@ -32,7 +29,7 @@ export class Engine {
#mutationObserver: MutationObserver | null = null

// Map of cleanup functions by element, keyed by the dataset key and value
#removals = new Map<Element, Map<string, OnRemovalFn>>()
#removals = new Map<Element, Map<number, OnRemovalFn>>()

get signals() {
return this.#signals
Expand All @@ -48,7 +45,7 @@ export class Engine {
effect: (cb: () => void): OnRemovalFn => effect(cb),
actions: this.#actions,
plugin,
applyAttributePlugin: that.#applyAttributePlugin.bind(that),
applyPluginsTo: that.#apply.bind(that),
}

let globalInitializer: GlobalInitializer | undefined
Expand Down Expand Up @@ -85,31 +82,40 @@ export class Engine {
return a.name.localeCompare(b.name)
})

this.#debouncedApply()
}

// Add a debounce so that it is only applyied once, regardless of how many times the load function is called
#debouncedApply = debounce(() => {
this.#apply(document.body)
this.#apply(document.documentElement)
this.#observe()
}, 1)
}

// Apply all plugins to the element and its children
#apply(rootElement: Element) {
walkDOM(rootElement, (el) => {
// Cleanup any existing removal functions
const elRemovals = this.#removals.get(el)
if (elRemovals) {
for (const [, removalFn] of elRemovals) {
removalFn()
}
this.#removals.delete(el)
}
// Check if the element has any data attributes already
const toApply = new Array<string>()
const elCleanups = this.#removals.get(el) || new Map()
const toCleanup = new Map<number, OnRemovalFn>([...elCleanups])
const hashes = new Map<string, number>()

// Apply the plugins to the element in order of application
// since DOMStringMap is ordered, we can be deterministic
for (const datasetKey of Object.keys(el.dataset)) {
this.#applyAttributePlugin(el, datasetKey)
const datasetValue = el.dataset[datasetKey] || ''
const currentHash = attrHash(datasetKey, datasetValue)
hashes.set(datasetKey, currentHash)

// If the hash hasn't changed, ignore
// otherwise keep the old cleanup and add new to applys
if (elCleanups.has(currentHash)) {
toCleanup.delete(currentHash)
} else {
toApply.push(datasetKey)
}
}

// Clean up any old plugins and apply the new ones
for (const [_, cleanup] of toCleanup) cleanup()
for (const key of toApply) {
const h = hashes.get(key)!
this.#applyAttributePlugin(el, key, h)
}
})
}
Expand All @@ -121,31 +127,23 @@ export class Engine {
}

this.#mutationObserver = new MutationObserver((mutations) => {
const toRemove = new Set<HTMLorSVGElement>()
const toApply = new Set<HTMLorSVGElement>()
for (const {
target,
type,
attributeName,
oldValue,
addedNodes,
removedNodes,
} of mutations) {
switch (type) {
case 'childList':
{
for (const node of removedNodes) {
const el = node as HTMLorSVGElement
const elRemovals = this.#removals.get(el)
if (!elRemovals) continue

for (const [_, removalFn] of elRemovals) {
removalFn()
}
this.#removals.delete(el)
toRemove.add(node as HTMLorSVGElement)
}

for (const node of addedNodes) {
const el = node as HTMLorSVGElement
this.#apply(el)
toApply.add(node as HTMLorSVGElement)
}
}
break
Expand All @@ -157,34 +155,25 @@ export class Engine {
if (!attributeName?.startsWith(requiredPrefix)) {
break
}

const el = target as HTMLorSVGElement
const datasetKey = camel(
attributeName.slice(datasetPrefix.length),
)

// If the value has changed, clean up the old value
if (oldValue !== null && el.dataset[datasetKey] !== oldValue) {
const elRemovals = this.#removals.get(el)
if (elRemovals) {
const rk = removalKey(datasetKey, oldValue)
const removalFn = elRemovals.get(rk)
if (removalFn) {
removalFn()
elRemovals.delete(rk)
}
}
}

// Apply the plugin only if the dataset key exists
if (datasetKey in el.dataset) {
this.#applyAttributePlugin(el, datasetKey)
}
toApply.add(target as HTMLorSVGElement)
}
break
}
}
}
for (const el of toRemove) {
const elTracking = this.#removals.get(el)
if (elTracking) {
for (const [h, cleanup] of elTracking) {
cleanup()
elTracking.delete(h)
}
if (elTracking.size === 0) {
this.#removals.delete(el)
}
}
}
for (const el of toApply) this.#apply(el)
})

this.#mutationObserver.observe(document.body, {
Expand All @@ -195,7 +184,11 @@ export class Engine {
})
}

#applyAttributePlugin(el: HTMLorSVGElement, camelCasedKey: string) {
#applyAttributePlugin(
el: HTMLorSVGElement,
camelCasedKey: string,
hash: number,
) {
// Extract the raw key from the dataset
const rawKey = camelCasedKey.slice(this.aliasPrefix.length)

Expand All @@ -205,16 +198,6 @@ export class Engine {
// Skip if no plugin is found
if (!plugin) return

const elAttr = this.#removals.get(el)
if (elAttr) {
for (const [k, removalFn] of elAttr) {
if (k.startsWith(camelCasedKey)) {
removalFn()
elAttr.delete(k)
}
}
}

// Ensure the element has an id
if (!el.id.length) el.id = elUniqId(el)

Expand All @@ -234,7 +217,7 @@ export class Engine {
get signals() {
return that.#signals
},
applyAttributePlugin: that.#applyAttributePlugin.bind(that),
applyPluginsTo: that.#apply.bind(that),
effect: (cb: () => void): OnRemovalFn => effect(cb),
actions: this.#actions,
genRX: () => this.#genRX(ctx, ...(plugin.argNames || [])),
Expand Down Expand Up @@ -281,20 +264,14 @@ export class Engine {
}

// Load the plugin and store any cleanup functions
const removalFn = plugin.onLoad(ctx)
if (removalFn) {
let elRemovals = this.#removals.get(el)
if (!elRemovals) {
elRemovals = new Map()
this.#removals.set(el, elRemovals)
const cleanup = plugin.onLoad(ctx)
if (cleanup) {
let elTracking = this.#removals.get(el)
if (!elTracking) {
elTracking = new Map()
this.#removals.set(el, elTracking)
}
elRemovals.set(removalKey(camelCasedKey, value), removalFn)
}

// Remove the attribute if required
const removeOnLoad = plugin.removeOnLoad
if (removeOnLoad && removeOnLoad(rawKey) === true) {
delete el.dataset[camelCasedKey]
elTracking.set(hash, cleanup)
}
}

Expand Down Expand Up @@ -337,7 +314,7 @@ export class Engine {
const escapeRe = new RegExp(`(?:${DSP})(.*?)(?:${DSS})`, 'gm')
for (const match of userExpression.matchAll(escapeRe)) {
const k = match[1]
const v = new Hash('dsEscaped').with(k).value
const v = new Hash('dsEscaped').with(k).string
escaped.set(v, k)
userExpression = userExpression.replace(DSP + k + DSS, v)
}
Expand Down
3 changes: 1 addition & 2 deletions library/src/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ export interface AttributePlugin extends DatastarPlugin {
type: PluginType.Attribute
onGlobalInit?: (ctx: InitContext) => void // Called once on registration of the plugin
onLoad: (ctx: RuntimeContext) => OnRemovalFn | void // Return a function to be called on removal
removeOnLoad?: (rawKey: string) => boolean // Return whether the attribute key should be removed after onLoad (useful for plugin attributes you don’t want reapplied)
mods?: Set<string> // If not provided, all modifiers are allowed
keyReq?: Requirement // The rules for the key requirements
valReq?: Requirement // The rules for the value requirements
Expand Down Expand Up @@ -82,7 +81,7 @@ export type InitContext = {
signals: SignalsRoot
effect: (fn: EffectFn) => OnRemovalFn
actions: Readonly<ActionPlugins>
applyAttributePlugin: (el: HTMLorSVGElement, datasetKey: string) => void
applyPluginsTo: (el: HTMLorSVGElement) => void
}

export type HTMLorSVGElement = Element & (HTMLElement | SVGElement)
Expand Down
1 change: 0 additions & 1 deletion library/src/plugins/official/dom/attributes/on.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export const On: AttributePlugin = {
keyReq: Requirement.Must,
valReq: Requirement.Must,
argNames: [EVT],
removeOnLoad: (rawKey: string) => rawKey.startsWith('onLoad'),
onLoad: ({ el, key, mods, rawKey, signals, value, effect, genRX }) => {
const rx = genRX()
let target: Element | Window | Document = el
Expand Down
15 changes: 13 additions & 2 deletions library/src/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,24 @@ export class Hash {
this.#prefix = prefix
}

with(x: number | string) {
with(x: number | string | boolean): Hash {
if (typeof x === 'string') {
for (const c of x.split('')) {
this.with(c.charCodeAt(0))
}
} else if (typeof x === 'boolean') {
this.with(1 << (x ? 7 : 3))
} else {
this.#value = (this.#value << 5) - this.#value + x
}
return this
}

get value() {
return this.#value
}

get string() {
return this.#prefix + Math.abs(this.#value).toString(36)
}
}
Expand All @@ -40,7 +47,11 @@ export function elUniqId(el: Element) {

currentEl = p as Element
}
return hash.value
return hash.string
}

export function attrHash(key: string, val: string) {
return new Hash().with(key).with(val).value
}

export function walkDOM(
Expand Down
4 changes: 2 additions & 2 deletions sdk/go/consts.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.