Fix merge fragments (#711)

* Restore elUniqId, removeOnLoad

* Store cleanup function only if not removed

* Restore elUniqId (again)

* Fix empty value being replaced

* Rehash the cleanup functions

* Cleanup

* Fix persist example

* Use el.id

* Remove `removeOnLoad`

* Restore check for existing mutationObserver

* Move

* Add release notes

* Improve tests
This commit is contained in:
Ben Croker 2025-02-27 21:16:50 -06:00 committed by GitHub
parent a7df17505f
commit e40db72ed5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 213 additions and 152 deletions

View File

@ -4,27 +4,10 @@ Each tagged version of Datastar is accompanied by a release note. Read the [rele
# WIP Release Notes
## v1.0.0-beta.8
### Added
- Added the ability for checkbox input elements to set bound signals to an array of values by predefining the signal as an array ([#664](https://github.com/starfederation/datastar/issues/674)).
### Changed
- 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 elements `value` attribute, provided the signal has not already been defined ([#685](https://github.com/starfederation/datastar/issues/685)).
- The expression passed into `data-on-signals-change` is no longer executed on page load ([#682](https://github.com/starfederation/datastar/issues/682)).
- 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)).
## v1.0.0-beta.9
### 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 `data-attr` was not removing the element attribute when using object syntax and the value was `false` ([#693](https://github.com/starfederation/datastar/issues/693)).
### Removed
- Removed the ability to import the Datastar class. The `apply`, `load`, and `setAlias` functions are exported instead.
- Fixed a bug in which `data-signals` was being reapplied each time any attribute changed on an element ([#709](https://github.com/starfederation/datastar/issues/709)).
- Fixed a bug in which focus was not being restored to input elements after merging fragments ([#710](https://github.com/starfederation/datastar/issues/710)).
- Fixed a bug in which signals bound to text input elements with a `value` attribute were being reset to the value when the entered value was empty.

View File

@ -10,7 +10,7 @@
Datastar helps you build reactive web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework.
Getting started is as easy as adding a single 14.3 KiB script tag to your HTML.
Getting started is as easy as adding a single 14.4 KiB script tag to your HTML.
```html
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-beta.8/bundles/datastar.js"></script>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -10,7 +10,7 @@
Datastar helps you build reactive web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework.
Getting started is as easy as adding a single 14.3 KiB script tag to your HTML.
Getting started is as easy as adding a single 14.4 KiB script tag to your HTML.
```html
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-beta.8/bundles/datastar.js"></script>

View File

@ -25,23 +25,25 @@ const plugins: AttributePlugin[] = []
const actions: ActionPlugins = {}
const watchers: WatcherPlugin[] = []
// Map of cleanup functions by element ID, keyed by a dataset key-value hash
const removals = new Map<string, Map<number, OnRemovalFn>>()
let mutationObserver: MutationObserver | null = null
let alias = ''
export function setAlias(value: string) {
alias = value
}
let mutationObserver: MutationObserver | null = null
// Map of cleanup functions by element, keyed by the dataset key and value
const removals = new Map<Element, Map<number, OnRemovalFn>>()
export function load(...pluginsToLoad: DatastarPlugin[]) {
for (const plugin of pluginsToLoad) {
const ctx: InitContext = {
plugin,
signals,
effect: (cb: () => void): OnRemovalFn => effect(cb),
actions: actions,
plugin,
apply,
actions,
removals,
applyToElement,
}
let globalInitializer: GlobalInitializer | undefined
@ -79,14 +81,19 @@ export function load(...pluginsToLoad: DatastarPlugin[]) {
})
}
// Apply all plugins to all elements in the DOM
export function apply() {
applyToElement(document.documentElement)
observe()
}
// Apply all plugins to the element and its children
export function apply(
rootElement: HTMLorSVGElement = document.documentElement,
) {
function applyToElement(rootElement: HTMLorSVGElement) {
walkDOM(rootElement, (el) => {
// Check if the element has any data attributes already
const toApply = new Array<string>()
const elCleanups = removals.get(el) || new Map()
const elCleanups = removals.get(el.id) || new Map()
const toCleanup = new Map<number, OnRemovalFn>([...elCleanups])
const hashes = new Map<string, number>()
@ -112,14 +119,14 @@ export function apply(
}
// Clean up any old plugins and apply the new ones
for (const [_, cleanup] of toCleanup) cleanup()
for (const [_, cleanup] of toCleanup) {
cleanup()
}
for (const key of toApply) {
const h = hashes.get(key)!
applyAttributePlugin(el, key, h)
}
})
observe()
}
// Set up a mutation observer to run plugin removal and apply functions
@ -151,19 +158,19 @@ function observe() {
}
}
for (const el of toRemove) {
const elTracking = removals.get(el)
const elTracking = removals.get(el.id)
if (elTracking) {
for (const [h, cleanup] of elTracking) {
for (const [hash, cleanup] of elTracking) {
cleanup()
elTracking.delete(h)
elTracking.delete(hash)
}
if (elTracking.size === 0) {
removals.delete(el)
removals.delete(el.id)
}
}
}
for (const el of toApply) {
apply(el)
applyToElement(el)
}
})
@ -205,9 +212,10 @@ function applyAttributePlugin(
// Create the runtime context
const ctx: RuntimeContext = {
signals,
apply,
applyToElement,
effect: (cb: () => void): OnRemovalFn => effect(cb),
actions: actions,
actions,
removals,
genRX: () => genRX(ctx, ...(plugin.argNames || [])),
plugin,
el,
@ -251,17 +259,17 @@ function applyAttributePlugin(
ctx.mods.set(camel(label), new Set(mod.map((t) => t.toLowerCase())))
}
// Load the plugin and store any cleanup functions
const cleanup = plugin.onLoad(ctx)
if (cleanup) {
let elTracking = removals.get(el)
// Load the plugin
const cleanup = plugin.onLoad(ctx) ?? (() => {})
// Store the cleanup function
let elTracking = removals.get(el.id)
if (!elTracking) {
elTracking = new Map()
removals.set(el, elTracking)
removals.set(el.id, elTracking)
}
elTracking.set(hash, cleanup)
}
}
function genRX(
ctx: RuntimeContext,

View File

@ -81,7 +81,8 @@ export type InitContext = {
signals: SignalsRoot
effect: (fn: EffectFn) => OnRemovalFn
actions: Readonly<ActionPlugins>
apply: (el?: HTMLorSVGElement) => void
removals: Map<string, Map<number, OnRemovalFn>>
applyToElement: (el: HTMLorSVGElement) => void
}
export type HTMLorSVGElement = Element & (HTMLElement | SVGElement)

View File

@ -11,10 +11,12 @@ import {
} from '../../../../engine/consts'
import { initErr } from '../../../../engine/errors'
import {
type HTMLorSVGElement,
type InitContext,
PluginType,
type WatcherPlugin,
} from '../../../../engine/types'
import { attrHash, elUniqId, walkDOM } from '../../../../utils/dom'
import { isBoolString } from '../../../../utils/text'
import {
docWithViewTransitionAPI,
@ -85,7 +87,25 @@ function applyToTargets(
const modifiedTarget = initialTarget
switch (mergeMode) {
case FragmentMergeModes.Morph: {
Idiomorph.morph(modifiedTarget, fragment.cloneNode(true))
const fragmentWithIDs = fragment.cloneNode(true) as HTMLorSVGElement
walkDOM(fragmentWithIDs, (el) => {
if (!el.id?.length && Object.keys(el.dataset).length) {
el.id = elUniqId(el)
}
// Rehash the cleanup functions for this element to ensure that plugins are cleaned up and reapplied after merging.
const elTracking = ctx.removals.get(el.id)
if (elTracking) {
const newElTracking = new Map()
for (const [key, cleanup] of elTracking) {
const newKey = attrHash(key, key)
newElTracking.set(newKey, cleanup)
elTracking.delete(key)
}
ctx.removals.set(el.id, newElTracking)
}
})
Idiomorph.morph(modifiedTarget, fragmentWithIDs)
break
}
case FragmentMergeModes.Inner:

View File

@ -128,7 +128,7 @@ export const Bind: AttributePlugin = {
const current = signals.value(signalName)
const input = (el as HTMLInputElement) || (el as HTMLElement)
const value = input.value || input.getAttribute('value') || ''
const value = input.value || ''
if (isCheckbox) {
const checked = input.checked || input.getAttribute('checked') === 'true'
@ -190,7 +190,7 @@ export const Bind: AttributePlugin = {
for (const event of updateEvents) {
el.addEventListener(event, el2sig)
}
const elSigClean = effect(() => setFromSignal())
/*
* The signal value needs to be updated after the "pageshow" event.
* Sometimes, the browser might populate inputs with previous values
@ -200,11 +200,14 @@ export const Bind: AttributePlugin = {
* https://web.dev/articles/bfcache
*/
const onPageshow = (ev: PageTransitionEvent) => {
if (!ev.persisted) return
if (ev.persisted) {
el2sig()
}
}
window.addEventListener("pageshow", onPageshow)
const elSigClean = effect(() => setFromSignal())
return () => {
elSigClean()
for (const event of updateEvents) {

View File

@ -51,7 +51,7 @@ export function elUniqId(el: Element) {
return hash.string
}
export function attrHash(key: string, val: string) {
export function attrHash(key: number | string, val: number | string) {
return new Hash().with(key).with(val).value
}

4
sdk/go/consts.go generated
View File

@ -7,8 +7,8 @@ import "time"
const (
DatastarKey = "datastar"
Version = "1.0.0-beta.8"
VersionClientByteSize = 39499
VersionClientByteSizeGzip = 14651
VersionClientByteSize = 39747
VersionClientByteSizeGzip = 14773
//region Default durations

View File

@ -40,7 +40,8 @@ func setupTests(ctx context.Context, router chi.Router) (err error) {
{ID: "key_casing"},
{ID: "local_signals"},
{ID: "merge_fragment"},
{ID: "merge_fragment_signal"},
{ID: "merge_fragment_on_load"},
{ID: "merge_fragment_signals"},
{ID: "merge_fragment_whitespace"},
{ID: "on_load"},
{ID: "radio_input"},
@ -104,8 +105,9 @@ func setupTests(ctx context.Context, router chi.Router) (err error) {
if err := errors.Join(
setupTestsMergeFragment(testsRouter),
setupTestsMergeFragmentOnLoad(testsRouter),
setupTestsMergeFragmentSignals(testsRouter),
setupTestsMergeFragmentWhitespace(testsRouter),
setupTestsMergeFragmentSignal(testsRouter),
setupTestsOnLoad(testsRouter),
setupTestsRemoveFragment(testsRouter),
); err != nil {

View File

@ -0,0 +1,18 @@
package site
import (
"net/http"
"github.com/go-chi/chi/v5"
datastar "github.com/starfederation/datastar/sdk/go"
)
func setupTestsMergeFragmentOnLoad(testsRouter chi.Router) error {
testsRouter.Get("/merge_fragment_on_load/data", func(w http.ResponseWriter, r *http.Request) {
sse := datastar.NewSSE(w, r)
sse.MergeFragments(`<div id="content" data-on-load="$result = 1"></div>`)
})
return nil
}

View File

@ -1,19 +0,0 @@
package site
import (
"net/http"
"github.com/go-chi/chi/v5"
datastar "github.com/starfederation/datastar/sdk/go"
)
func setupTestsMergeFragmentSignal(testsRouter chi.Router) error {
testsRouter.Get("/merge_fragment_signal/data", func(w http.ResponseWriter, r *http.Request) {
sse := datastar.NewSSE(w, r)
c := mergeFragmentSignalTest()
sse.MergeFragmentTempl(c)
})
return nil
}

View File

@ -1,7 +0,0 @@
package site
templ mergeFragmentSignalTest() {
<div id="content" data-signals-result="1">
<button data-on-click="@get('/tests/merge_fragment_signal/data')" class="btn">Merge</button>
</div>
}

View File

@ -0,0 +1,19 @@
package site
import (
"net/http"
"github.com/go-chi/chi/v5"
datastar "github.com/starfederation/datastar/sdk/go"
)
func setupTestsMergeFragmentSignals(testsRouter chi.Router) error {
testsRouter.Get("/merge_fragment_signals/data", func(w http.ResponseWriter, r *http.Request) {
sse := datastar.NewSSE(w, r)
sse.MergeFragments(`<div id="content" data-signals-result="1"></div>
}`)
})
return nil
}

View File

@ -0,0 +1,9 @@
package smoketests
import (
"testing"
)
func TestUnitMergeFragmentOnLoad(t *testing.T) {
setupPageTestOnClick(t, "tests/merge_fragment_on_load")
}

View File

@ -1,9 +0,0 @@
package smoketests
import (
"testing"
)
func TestUnitMergeFragmentSignal(t *testing.T) {
setupPageTestOnClick(t, "tests/merge_fragment_signal")
}

View File

@ -0,0 +1,9 @@
package smoketests
import (
"testing"
)
func TestUnitMergeFragmentSignals(t *testing.T) {
setupPageTestOnClick(t, "tests/merge_fragment_signals")
}

View File

@ -29,7 +29,7 @@ func TestExamplePersist(t *testing.T) {
page.MustWaitIdle()
input := page.MustElement("#keyInput")
input := page.MustElement("#keyInput1")
revisedExpected := "This is a test"
input.MustInput(revisedExpected)

View File

@ -1,11 +1,11 @@
## Demo
<div data-signals="{namespace: {test1: 'foo', test2: 'bar', test3: 'baz'}}" data-persist-foo="namespace.test1 namespace.test3">
<input id="keyInput" class="input input-bordered" data-bind="namespace.test1"/>
<input id="keyInput1" data-bind="namespace.test1" class="input input-bordered" />
<br>
<input id="keyInput" class="input input-bordered" data-bind="namespace.test2"/>
<input data-bind="namespace.test2" class="input input-bordered" />
<br>
<input id="keyInput" class="input input-bordered" data-bind="namespace.test3"/>
<input data-bind="namespace.test3" class="input input-bordered" />
<pre data-text="ctx.signals.JSON()">Replace me</pre>
</div>
@ -16,9 +16,9 @@
data-signals="{namespace: {test1: 'foo', test2: 'bar', test3: 'baz'}}"
data-persist-foo="namespace.test1 namespace.test3"
>
<input class="input input-bordered" data-bind="namespace.test1" />
<input class="input input-bordered" data-bind="namespace.test2" />
<input class="input input-bordered" data-bind="namespace.test3" />
<input data-bind="namespace.test1" />
<input data-bind="namespace.test2" />
<input data-bind="namespace.test3" />
<pre data-text="ctx.signals.JSON()">Replace me</pre>
</div>
```

View File

@ -0,0 +1,10 @@
# Merge Fregment Containing On Event
Tests that merging a fragment containing an `on` event works.
<div>
<div id="content" data-signals-hidden="false" data-show="!$hidden"><button data-on-click="$hidden = true" data-show="!$hidden" class="btn">Hide</button><input data-bind-name class="input input-bordered" /><button data-on-click="@get('/tests/merge_fragment_containing_on_event/data')" class="btn">Merge</button></div>
<hr />
<button id="clickable" data-on-click="@get('/tests/merge_fragment_containing_on_event/data')" class="btn">Merge</button>
<pre data-text="ctx.signals.JSON()"></pre>
</div>

View File

@ -0,0 +1,13 @@
# Merge Fregment On Load
Tests that merging a fragment containing `data-on-load` works.
<div>
<div id="content" data-signals-result="0" data-on-load="$result = 0"></div>
<button id="clickable" data-on-click="@get('/tests/merge_fragment_on_load/data')" class="btn">Merge</button>
<hr />
Result:
<code id="result" data-text="$result"></code>
<hr />
Expected result on click: <code>1</code>
</div>

View File

@ -1,12 +0,0 @@
# Merge Fregment Signal
Tests that merging a fragment containing `data-signals-*` works.
<div>
<div id="content" data-signals-result="0"><button id="clickable" data-on-click="@get('/tests/merge_fragment_signal/data')" class="btn">Merge</button></div>
<hr />
Result:
<code id="result" data-text="$result"></code>
<hr />
Expected result on click: <code>1</code>
</div>

View File

@ -0,0 +1,13 @@
# Merge Fregment Signals
Tests that merging a fragment containing `data-signals-*` works.
<div>
<div id="content" data-signals-result="0"></div>
<button id="clickable" data-on-click="@get('/tests/merge_fragment_signals/data')" class="btn">Merge</button>
<hr />
Result:
<code id="result" data-text="$result"></code>
<hr />
Expected result on click: <code>1</code>
</div>