Skip to content

Instantly share code, notes, and snippets.

@phacks
Last active February 10, 2024 10:05
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save phacks/0991e8340bff6184c2c9d6793600315a to your computer and use it in GitHub Desktop.
Save phacks/0991e8340bff6184c2c9d6793600315a to your computer and use it in GitHub Desktop.
Alpine CSP limitations

Alpine CSP limitations

Passing parameters to functions

The following test fails with the CSP build:

test.csp('event object is not passed if other params are present',
    [html`
        <div x-data="test">
            <button x-on:click="baz('foo')"></button>

            <span x-text="foo"></span>
        </div>
    `,`
        Alpine.data('test', () => ({
            foo: 'bar',
            baz(word) { this.foo = word }
        }))
    `],
    ({ get }) => {
        get('span').should(haveText('bar'))
        get('button').click()
        get('span').should(haveText('foo'))
    }
)

You can work around the issue by passing arguments through data attributes:

test.csp('arguments can be passed through data attributes',
    [html`
        <div x-data="test">
            <button x-on:click="baz" data-baz-word="foo"></button>

            <span x-text="foo"></span>
        </div>
    `,`
        Alpine.data('test', () => ({
            foo: 'bar',
            baz() { this.foo = this.$el.dataset.bazWord }
        }))
    `],
    ({ get }) => {
        get('span').should(haveText('bar'))
        get('button').click()
        get('span').should(haveText('foo'))
    }
)

Accessing nested data

The following test fails with the CSP build:

test.csp('nested data modified in event listener updates affected attribute bindings',
    [html`
        <div x-data="test">
            <button x-on:click="change"></button>

            <span x-bind:foo="nested.foo"></span>
        </div>
    `,`
        Alpine.data('test', () => ({
            nested: { foo: 'bar' },
            change() { this.nested.foo = 'baz' }
        }))
    `],
    ({ get }) => {
        get('span').should(haveAttribute('foo', 'bar'))
        get('button').click()
        get('span').should(haveAttribute('foo', 'baz'))
    }
)

You can work around the issue by creating a method that accesses the nested attribute:

test.csp('nested data modified in event listener updates affected attribute bindings',
    [html`
        <div x-data="test">
            <button x-on:click="change"></button>

            <span x-bind:foo="nestedDotFoo"></span>
        </div>
    `,`
        Alpine.data('test', () => ({
            nested: { foo: 'bar' },
            nestedDotFoo() { return this.nested.foo },
            change() { this.nested.foo = 'baz' }
        }))
    `],
    ({ get }) => {
        get('span').should(haveAttribute('foo', 'bar'))
        get('button').click()
        get('span').should(haveAttribute('foo', 'baz'))
    }
)

x-model bound to an input does not react to changes

The following test fails with the CSP build:

test.csp('x-model updates value when updated via input event',
    [html`
        <div x-data="test">
            <input x-model="foo"></input>
            <span x-text="foo"></span>
        </div>
    `,
    `
        Alpine.data('test', () => ({
            foo: 'bar'
        }))
    `],
    ({ get }) => {
        get('span').should(haveText('bar'))
        get('input').type('baz')
        get('span').should(haveText('barbaz'))
    }
)

I was able to work around this issue with the changes in alpine-csp.patch (note: the patch hasn’t be thoroughly tested).

Accessing x-for values

The following test fails with the CSP build:

test.csp('renders loops with x-for',
    [html`
        <div x-data="test">
            <template x-for="item in items">
                <span x-text="item.name"></span>
            </template>
        </div>
    `,`
        Alpine.data('test', () => ({
            items: [{name: 'foo', value: 1}]
        }))
    `],
    ({ get }) => {
        get('span:nth-of-type(1)').should(haveText('foo'))
    }
)

In order to access items (or indices) generated by x-for, you can use the $data variable:

test.csp('renders loops with x-for',
    [html`
        <div x-data="test">
            <template x-for="item in items">
                <span x-text="itemName"></span>
            </template>
        </div>
    `,`
        Alpine.data('test', () => ({
            items: [{name: 'foo', value: 1}],
            itemName() { return this.$data.item.name }
        }))
    `],
    ({ get }) => {
        get('span:nth-of-type(1)').should(haveText('foo'))
    }
)

Nested x-fors

The following test fails with the CSP build:

test.csp('nested x-for',
    [html`
        <div x-data="test">
            <button x-on:click="change">click me</button>
            <template x-for="foo in foos">
                <h1>
                    <template x-for="bar in foo.bars">
                        <h2 x-text="bar"></h2>
                    </template>
                </h1>
            </template>
        </div>
    `,`
        Alpine.data('test', () => ({
            foos: [ {bars: ['bob', 'lob']} ],
            change() { this.foos = [ {bars: ['bob', 'lob']}, {bars: ['law']} ] },
        }))
    `],
    ({ get }) => {
        get('h1:nth-of-type(1) h2:nth-of-type(1)').should(exist())
        get('h1:nth-of-type(1) h2:nth-of-type(2)').should(exist())
        get('h1:nth-of-type(2) h2:nth-of-type(1)').should(notExist())
        get('button').click()
        get('h1:nth-of-type(1) h2:nth-of-type(1)').should(exist())
        get('h1:nth-of-type(1) h2:nth-of-type(2)').should(exist())
        get('h1:nth-of-type(2) h2:nth-of-type(1)').should(exist())
    }
)

You can nest x-fors by created a method to access the nested elements:

test.csp('nested x-for',
    [html`
        <div x-data="test">
            <button x-on:click="change">click me</button>
            <template x-for="foo in foos">
                <h1>
                    <template x-for="bar in fooDotBars">
                        <h2 x-text="bar"></h2>
                    </template>
                </h1>
            </template>
        </div>
    `,`
        Alpine.data('test', () => ({
            foos: [ {bars: ['bob', 'lob']} ],
            fooDotBars() { return this.$data.foo.bars },
            change() { this.foos = [ {bars: ['bob', 'lob']}, {bars: ['law']} ] },
        }))
    `],
    ({ get }) => {
        get('h1:nth-of-type(1) h2:nth-of-type(1)').should(exist())
        get('h1:nth-of-type(1) h2:nth-of-type(2)').should(exist())
        get('h1:nth-of-type(2) h2:nth-of-type(1)').should(notExist())
        get('button').click()
        get('h1:nth-of-type(1) h2:nth-of-type(1)').should(exist())
        get('h1:nth-of-type(1) h2:nth-of-type(2)').should(exist())
        get('h1:nth-of-type(2) h2:nth-of-type(1)').should(exist())
    }
)

Specifying a :key in an x-for

The following test fails with the CSP build:

test.csp('x-for updates the right elements when new item are inserted at the beginning of the list',
    [html`
        <div x-data="test">
            <button x-on:click="change">click me</button>

            <template x-for="item in items" :key="item.key">
                <span x-text="itemDotName"></span>
            </template>
        </div>
    `,`
        Alpine.data('test', () => ({
            items: [{name: 'one', key: '1'}, {name: 'two', key: '2'}],
            itemDotName() { return this.$data.item.name },
            change() { this.items = [{name: 'zero', key: '0'}, {name: 'one', key: '1'}, {name: 'two', key: '2'}] },
        }))
    `],
    ({ get }) => {
        get('span:nth-of-type(1)').should(haveText('one'))
        get('span:nth-of-type(2)').should(haveText('two'))
        get('button').click()
        get('span:nth-of-type(1)').should(haveText('zero'))
        get('span:nth-of-type(2)').should(haveText('one'))
        get('span:nth-of-type(3)').should(haveText('two'))
    }
)

I was able to work around this issue with the following patch (note: the patch hasn’t be thoroughly tested):

Git Patch
diff --git a/packages/alpinejs/src/directives/x-for.js b/packages/alpinejs/src/directives/x-for.js
index 0521336..ba5ab57 100644
--- a/packages/alpinejs/src/directives/x-for.js
+++ b/packages/alpinejs/src/directives/x-for.js
@@ -60,7 +60,7 @@ function loop(el, iteratorNames, evaluateItems, evaluateKey) {
             items = Object.entries(items).map(([key, value]) => {
                 let scope = getIterationScopeVariables(iteratorNames, value, key, items)
 
-                evaluateKey(value => keys.push(value), { scope: { index: key, ...scope} })
+                evaluateKey(value => keys.push(value), { scope: { index: key, ...scope}, params: [value] } )
 
                 scopes.push(scope)
             })
@@ -68,7 +68,7 @@ function loop(el, iteratorNames, evaluateItems, evaluateKey) {
             for (let i = 0; i < items.length; i++) {
                 let scope = getIterationScopeVariables(iteratorNames, items[i], i, items)
 
-                evaluateKey(value => keys.push(value), { scope: { index: i, ...scope} })
+                evaluateKey(value => keys.push(value), { scope: { index: i, ...scope}, params: [items[i]] })
 
                 scopes.push(scope)
             }

And specify a function to retrieve the key:

test.csp('x-for updates the right elements when new item are inserted at the beginning of the list',
    [html`
        <div x-data="test">
            <button x-on:click="change">click me</button>

            <template x-for="item in items" :key="itemDotKey">
                <span x-text="itemDotName"></span>
            </template>
        </div>
    `,`
        Alpine.data('test', () => ({
            items: [{name: 'one', key: '1'}, {name: 'two', key: '2'}],
            itemDotKey(item) { return item.key },
            itemDotName() { return this.$data.item.name },
            change() { this.items = [{name: 'zero', key: '0'}, {name: 'one', key: '1'}, {name: 'two', key: '2'}] },
        }))
    `],
    ({ get }) => {
        get('span:nth-of-type(1)').should(haveText('one'))
        get('span:nth-of-type(2)').should(haveText('two'))
        get('button').click()
        get('span:nth-of-type(1)').should(haveText('zero'))
        get('span:nth-of-type(2)').should(haveText('one'))
        get('span:nth-of-type(3)').should(haveText('two'))
    }
)

x-for over range using i in x syntax

The following test fails with the CSP build:

test.csp('x-for over range using i in x syntax',
    [html`
        <div x-data>
            <template x-for="i in 10">
                <span x-text="i"></span>
            </template>
        </div>
    `],
    ({ get }) => get('span').should(haveLength('10'))
)

I was able to work around this issue with the patch from x-model bound to an input does not react to changes

Using true/false, integers, or plain strings as inline values

The following test fails with the CSP build:

test.csp('renders children in the right order when combined with x-if',
    [html`
        <div x-data="test">
            <template x-for="item in items">
                <template x-if="true">
                    <span x-text="item"></span>
                </template>
            </template>
        </div>
    `,`
        Alpine.data('test', () => ({
            items: ['foo', 'bar']
        }))
    `],
    ({ get }) => {
        get('span:nth-of-type(1)').should(haveText('foo'))
        get('span:nth-of-type(2)').should(haveText('bar'))
    }
)

You can fix it by either creating isTrue() { return true }/isFalse() { return false } method in your x-data (and calling them instead of true / false), or apply the patch from alpine-csp.patch.

x-bind sets undefined nested keys to empty string

The following test fails with the CSP build:

test.csp('sets undefined nested keys to empty string',
    [html`
        <div x-data="test">
            <span x-bind:foo="nestedDotField">
        </div>
    `,`
        Alpine.data('test', () => ({
            nested: {},
            nestedDotField() { return this.nested.field }
        }))
    `],
    ({ get }) => get('span').should(haveAttribute('foo', ''))
)

A possible workaround is to explicitely return an empty string: nestedDotField() { return this.nested.field || '' }.

x-bind object syntax supports normal html attributes

The following test fails with the CSP build:

test.csp('x-bind object syntax supports normal html attributes',
    [html`
        <span x-data="test" x-bind="value" x-text="text"></span>
    `,`
        Alpine.data('test', () => ({
            value: { foo: 'bar' },
            text: 'text'
        }))
    `],
    ({ get }) => {
        get('span').should(haveAttribute('foo', 'bar'))
    }
)   

You can fix it by applying the patch from alpine-csp.patch.

diff --git a/packages/alpinejs/src/alpine.js b/packages/alpinejs/src/alpine.js
index d9da721..85041b5 100644
--- a/packages/alpinejs/src/alpine.js
+++ b/packages/alpinejs/src/alpine.js
@@ -3,7 +3,7 @@ import { mapAttributes, directive, setPrefix as prefix, prefix as prefixed } fro
import { start, addRootSelector, addInitSelector, closestRoot, findClosest, initTree, destroyTree, interceptInit } from './lifecycle'
import { mutateDom, deferMutations, flushAndStopDeferringMutations, startObservingMutations, stopObservingMutations } from './mutation'
import { mergeProxies, closestDataStack, addScopeToNode, scope as $data } from './scope'
-import { setEvaluator, evaluate, evaluateLater, dontAutoEvaluateFunctions } from './evaluator'
+import { setEvaluator, setAssignmentFunction, evaluate, evaluateLater, dontAutoEvaluateFunctions } from './evaluator'
import { transition } from './directives/x-transition'
import { clone, skipDuringClone, onlyDuringClone } from './clone'
import { interceptor } from './interceptor'
@@ -42,6 +42,7 @@ let Alpine = {
evaluateLater,
interceptInit,
setEvaluator,
+ setAssignmentFunction,
mergeProxies,
findClosest,
closestRoot,
diff --git a/packages/alpinejs/src/directives/x-model.js b/packages/alpinejs/src/directives/x-model.js
index b623748..1336035 100644
--- a/packages/alpinejs/src/directives/x-model.js
+++ b/packages/alpinejs/src/directives/x-model.js
@@ -1,4 +1,4 @@
-import { evaluateLater } from '../evaluator'
+import { evaluateLater, assignmentFunction } from '../evaluator'
import { directive } from '../directives'
import { mutateDom } from '../mutation'
import { nextTick } from '../nextTick'
@@ -6,6 +6,7 @@ import bind from '../utils/bind'
import on from '../utils/on'
import { warn } from '../utils/warn'
import { isCloning } from '../clone'
+import { scope } from '../scope'
directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
let scopeTarget = el
@@ -15,15 +16,7 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
}
let evaluateGet = evaluateLater(scopeTarget, expression)
- let evaluateSet
-
- if (typeof expression === 'string') {
- evaluateSet = evaluateLater(scopeTarget, `${expression} = __placeholder`)
- } else if (typeof expression === 'function' && typeof expression() === 'string') {
- evaluateSet = evaluateLater(scopeTarget, `${expression()} = __placeholder`)
- } else {
- evaluateSet = () => {}
- }
+ let evaluateSet = (value) => assignmentFunction(scopeTarget, expression, value)
let getValue = () => {
let result
@@ -41,9 +34,7 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
if (isGetterSetter(result)) {
result.set(value)
} else {
- evaluateSet(() => {}, {
- scope: { '__placeholder': value }
- })
+ evaluateSet(value)
}
}
diff --git a/packages/alpinejs/src/evaluator.js b/packages/alpinejs/src/evaluator.js
index 95e0d89..234e9a2 100644
--- a/packages/alpinejs/src/evaluator.js
+++ b/packages/alpinejs/src/evaluator.js
@@ -26,12 +26,34 @@ export function evaluateLater(...args) {
return theEvaluatorFunction(...args)
}
+export function assignmentFunction(...args) {
+ return theAssignmentFunction(...args)
+}
+
+export function normalAssignmentFunction(scopeTarget, expression, value) {
+ let assignmentFunction
+ if (typeof expression === 'string') {
+ assignmentFunction = evaluateLater(scopeTarget, `${expression} = __placeholder`)
+ } else if (typeof expression === 'function' && typeof expression() === 'string') {
+ assignmentFunction = evaluateLater(scopeTarget, `${expression()} = __placeholder`)
+ } else {
+ assignmentFunction = (() => {})
+ }
+
+ return assignmentFunction(() => {}, {scope: { '__placeholder': value} })
+}
+
let theEvaluatorFunction = normalEvaluator
+let theAssignmentFunction = normalAssignmentFunction
export function setEvaluator(newEvaluator) {
theEvaluatorFunction = newEvaluator
}
+export function setAssignmentFunction(newAssignmentFunction) {
+ theAssignmentFunction = newAssignmentFunction
+}
+
export function normalEvaluator(el, expression) {
let overriddenMagics = {}
diff --git a/packages/csp/src/index.js b/packages/csp/src/index.js
index 9b5b26c..86fb859 100644
--- a/packages/csp/src/index.js
+++ b/packages/csp/src/index.js
@@ -1,6 +1,7 @@
import Alpine from 'alpinejs/src/alpine'
Alpine.setEvaluator(cspCompliantEvaluator)
+Alpine.setAssignmentFunction(cspCompliantAssignmentFunction)
import { reactive, effect, stop, toRaw } from '@vue/reactivity'
Alpine.setReactivityEngine({ reactive, effect, release: stop, raw: toRaw })
@@ -10,15 +11,31 @@ import 'alpinejs/src/directives/index'
import { closestDataStack, mergeProxies } from 'alpinejs/src/scope'
import { injectMagics } from 'alpinejs/src/magics'
-import { generateEvaluatorFromFunction, runIfTypeOfFunction } from 'alpinejs/src/evaluator'
+import { evaluateLater, generateEvaluatorFromFunction, runIfTypeOfFunction } from 'alpinejs/src/evaluator'
import { tryCatch } from 'alpinejs/src/utils/error'
+function cspCompliantAssignmentFunction(scopeTarget, expression, value) {
+ let assignmentFunction
+ if (typeof expression === 'string') {
+ assignmentFunction = evaluateLater(scopeTarget, '__assign')(() => {}, {params: [expression, value]})
+ } else if (typeof expression === 'function' && typeof expression() === 'string') {
+ assignmentFunction = evaluateLater(scopeTarget, '__assign')(() => {}, {params: [expression, value]})
+ } else {
+ assignmentFunction = (() => {})(() => {})
+ }
+
+ return assignmentFunction
+}
+
function cspCompliantEvaluator(el, expression) {
let overriddenMagics = {}
injectMagics(overriddenMagics, el)
- let dataStack = [overriddenMagics, ...closestDataStack(el)]
+ assignDataStack = {
+ __assign(assignee, value) { this[assignee] = value }
+ }
+ let dataStack = [overriddenMagics, assignDataStack, ...closestDataStack(el)]
if (typeof expression === 'function') {
return generateEvaluatorFromFunction(dataStack, expression)
@@ -29,10 +46,16 @@ function cspCompliantEvaluator(el, expression) {
if (completeScope[expression] !== undefined) {
runIfTypeOfFunction(receiver, completeScope[expression], completeScope, params)
+ } else if (isNumeric(expression)) {
+ runIfTypeOfFunction(receiver, Number(expression), completeScope, params)
+ } else if (isPlainString(expression)) {
+ runIfTypeOfFunction(receiver, expression.substring(1, expression.length - 1), completeScope, params)
+ }
}
return tryCatch.bind(null, el, expression, evaluator)
}
+function isNumeric(subject){
+ return ! Array.isArray(subject) && ! isNaN(subject)
+}
+
+function isPlainString(subject){
+ const plainStringRegex = /(^'[^']*'$)|(^"[^"]*"$)/
+ return subject.match(plainStringRegex);
+}
+
export default Alpine
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment