Skip to content
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
18 changes: 12 additions & 6 deletions .github/workflows/diffyne-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,19 @@ on:
- main

jobs:
static-analysis:
name: PHPStan Static Analysis (PHP ${{ matrix.php }})
approval:
name: CI Approval Gate
runs-on: ubuntu-latest
environment:
name: ci-approval
steps:
- name: Approval granted
run: echo "CI approval granted, proceeding with checks..."

static-analysis:
name: PHPStan Static Analysis (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
needs: approval
permissions:
contents: read
pull-requests: write
Expand Down Expand Up @@ -52,8 +60,7 @@ jobs:
format-check:
name: Code Formatting Check (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
environment:
name: ci-approval
needs: approval
permissions:
contents: read
pull-requests: write
Expand Down Expand Up @@ -92,8 +99,7 @@ jobs:
test:
name: Run Tests (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
environment:
name: ci-approval
needs: approval
permissions:
contents: read
pull-requests: write
Expand Down
11 changes: 9 additions & 2 deletions config/diffyne.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,15 @@
// HMAC signing key for state verification (defaults to APP_KEY)
'signing_key' => env('DIFFYNE_SIGNING_KEY'),

// Verify state signature on every request (recommended: true)
'verify_state' => env('DIFFYNE_VERIFY_STATE', true),
// Verify state signature on every request
// Options: 'strict' (verify all), 'property-updates' (only property updates), 'none' (disabled)
// Recommended: 'property-updates' for better UX while maintaining security
'verify_state' => env('DIFFYNE_VERIFY_STATE', 'property-updates'),

// Allow form submissions without strict signature verification
// When true, form submissions (call type) use lenient verification with reconstruction
// When false, form submissions require exact signature match
'lenient_form_verification' => env('DIFFYNE_LENIENT_FORMS', true),

// Rate limiting for component updates (requests per minute)
'rate_limit' => env('DIFFYNE_RATE_LIMIT', 60),
Expand Down
2 changes: 1 addition & 1 deletion public/js/diffyne.js

Large diffs are not rendered by default.

64 changes: 51 additions & 13 deletions resources/js/Diffyne.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ export class Diffyne {
signature,
vdom: this.vNodeConverter.buildVDOM(element),
});

const component = this.registry.get(id);
if (component) {
component.serverState = JSON.parse(JSON.stringify(state));
}

this.modelSync.sync(element, state);
this.eventBinder.bind(element, id);
Expand Down Expand Up @@ -174,6 +179,11 @@ export class Diffyne {
fingerprint: data.fingerprint,
vdom: this.vNodeConverter.buildVDOM(element),
});

const lazyComponent = this.registry.get(id);
if (lazyComponent) {
lazyComponent.serverState = JSON.parse(JSON.stringify(data.state));
}

this.modelSync.sync(element, data.state);
this.eventBinder.bind(element, id);
Expand Down Expand Up @@ -238,6 +248,8 @@ export class Diffyne {

this.loadingService.show(component.element);

const currentState = JSON.parse(JSON.stringify(component.state));

// Create abort controller for request cancellation
const abortController = new AbortController();
const requestId = this.getNextRequestId(componentId);
Expand All @@ -254,14 +266,14 @@ export class Diffyne {
componentClass: component.componentClass,
method,
params,
state: component.state,
state: currentState,
fingerprint: component.fingerprint,
signature: component.signature
});

// Check if this request is still valid (not superseded)
if (this.isRequestValid(componentId, requestId)) {
this.processResponse(componentId, response, requestId);
this.processResponse(componentId, response, requestId, 'call');
}
} catch (error) {
// Only handle error if request wasn't cancelled
Expand All @@ -287,35 +299,37 @@ export class Diffyne {
// Cancel any pending requests for this component
this.cancelPendingRequest(componentId);

// Store the original state and signature before any updates
const originalState = { ...component.state };
const originalSignature = component.signature;
const serverState = component.serverState || component.state;
const serverSignature = component.signature;

// Store the property being updated so we can preserve local changes for other properties
const updatingProperty = property;

// Create abort controller for request cancellation
const abortController = new AbortController();
const requestId = this.getNextRequestId(componentId);

this.pendingRequests.set(componentId, {
controller: abortController,
requestId: requestId
requestId: requestId,
updatingProperty: updatingProperty // Track which property is being updated
});

try {
// Send request with ORIGINAL state (before optimistic update)
const response = await this.transport.send({
type: 'update',
componentId,
componentClass: component.componentClass,
property,
value,
state: originalState, // Original state
state: serverState,
fingerprint: component.fingerprint,
signature: originalSignature // Original signature matches original state
signature: serverSignature
});

// Check if this request is still valid (not superseded)
if (this.isRequestValid(componentId, requestId)) {
this.processResponse(componentId, response, requestId);
this.processResponse(componentId, response, requestId, 'update', updatingProperty);
}
} catch (error) {
// Only handle error if request wasn't cancelled
Expand All @@ -330,7 +344,7 @@ export class Diffyne {
/**
* Process server response
*/
processResponse(componentId, response, requestId = null) {
processResponse(componentId, response, requestId = null, requestType = 'call', updatedProperty = null) {
const component = this.registry.get(componentId);
if (!component) return;

Expand Down Expand Up @@ -381,8 +395,32 @@ export class Diffyne {
}

if (state) {
component.updateState(state);
this.modelSync.sync(component.element, state);
if (requestType === 'update' && updatedProperty) {
const mergedState = { ...component.state };
if (state.hasOwnProperty(updatedProperty)) {
mergedState[updatedProperty] = state[updatedProperty];
}
component.updateState(mergedState);
component.serverState = JSON.parse(JSON.stringify(state));

const propertyValue = state[updatedProperty];
if (propertyValue !== undefined) {
const modelInputs = this.modelSync.findModelInputs(component.element);
modelInputs.forEach(input => {
const modelAttr = this.modelSync.findModelAttribute(input);
if (modelAttr) {
const property = input.getAttribute(modelAttr.name);
if (property === updatedProperty) {
this.modelSync.syncInput(input, propertyValue);
}
}
});
}
} else {
component.updateState(state);
component.serverState = JSON.parse(JSON.stringify(state));
this.modelSync.sync(component.element, state);
}
}

if (fingerprint) {
Expand Down
1 change: 1 addition & 0 deletions resources/js/core/Component.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export class Component {
this.componentName = data.componentName;
this.element = data.element;
this.state = data.state || {};
this.serverState = JSON.parse(JSON.stringify(data.state || {}));
this.fingerprint = data.fingerprint;
this.signature = data.signature;
this.vdom = data.vdom;
Expand Down
28 changes: 23 additions & 5 deletions resources/js/core/EventBinder.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ export class EventBinder {
* Bind model events (input/change)
*/
bindModelEvents(wrapper, componentId) {
// Helper to get the correct value based on input type
const getInputValue = (input) => {
if (input.type === 'checkbox') {
return input.checked;
} else if (input.type === 'radio') {
return input.checked ? input.value : undefined;
} else {
return input.value;
}
};

// Input events
wrapper.addEventListener('input', (e) => {
const target = e.target;
Expand All @@ -66,10 +77,11 @@ export class EventBinder {
if (modelAttr) {
const property = target.getAttribute(modelAttr.name);
const modifiers = this.parseModifiers(modelAttr.name, property);
const value = getInputValue(target);

if (modifiers.live) {
if (!target._diffyneModelHandler) {
let handler = (value) => this.modelHandler(componentId, modifiers.property, value);
let handler = (val) => this.modelHandler(componentId, modifiers.property, val);

if (modifiers.debounce) {
handler = debounce(handler, modifiers.debounce);
Expand All @@ -78,13 +90,13 @@ export class EventBinder {
target._diffyneModelHandler = handler;
}

target._diffyneModelHandler(target.value);
target._diffyneModelHandler(value);
} else if (target.tagName !== 'SELECT' &&
target.type !== 'checkbox' &&
target.type !== 'radio') {
// Only update local state for text inputs that won't trigger server request on change
// SELECT, checkbox, radio always trigger server requests, so don't update local state
this.localStateHandler(componentId, modifiers.property, target.value);
this.localStateHandler(componentId, modifiers.property, value);
}
}
});
Expand All @@ -97,13 +109,19 @@ export class EventBinder {
if (modelAttr) {
const property = target.getAttribute(modelAttr.name);
const modifiers = this.parseModifiers(modelAttr.name, property);
const value = getInputValue(target);

// For radio buttons, only send update if checked
if (target.type === 'radio' && !target.checked) {
return;
}

if (modifiers.lazy || target.tagName === 'SELECT' ||
target.type === 'checkbox' || target.type === 'radio') {
this.modelHandler(componentId, modifiers.property, target.value);
this.modelHandler(componentId, modifiers.property, value);
} else if (!modifiers.live) {
// Update local state for non-live inputs on change
this.localStateHandler(componentId, modifiers.property, target.value);
this.localStateHandler(componentId, modifiers.property, value);
}
}
});
Expand Down
26 changes: 23 additions & 3 deletions resources/js/services/ModelSyncService.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,33 @@ export class ModelSyncService {
* Sync single input value
*/
syncInput(input, value) {
const normalizedValue = value ?? '';

if (input.tagName === 'INPUT' || input.tagName === 'TEXTAREA') {
if (input.tagName === 'INPUT') {
if (input.type === 'checkbox') {
// For checkboxes, value is boolean
const checked = Boolean(value);
if (input.checked !== checked) {
input.checked = checked;
}
} else if (input.type === 'radio') {
// For radio buttons, check if this input's value matches the state value
const checked = input.value === String(value);
if (input.checked !== checked) {
input.checked = checked;
}
} else {
// For text inputs, textarea, etc.
const normalizedValue = value ?? '';
if (input.value !== normalizedValue) {
input.value = normalizedValue;
}
}
} else if (input.tagName === 'TEXTAREA') {
const normalizedValue = value ?? '';
if (input.value !== normalizedValue) {
input.value = normalizedValue;
}
} else if (input.tagName === 'SELECT') {
const normalizedValue = value ?? '';
if (input.value !== normalizedValue) {
input.value = normalizedValue;
}
Expand Down
6 changes: 4 additions & 2 deletions src/DiffyneServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,11 @@ protected function registerBladeDirectives(): void
return "<?php echo view('diffyne::scripts'); ?>";
});

// @diffyneStyles directive for including CSS (if needed)
// @diffyneStyles directive for including CSS and CSRF meta tag
Blade::directive('diffyneStyles', function () {
return '<!-- Diffyne styles -->';
$csrfToken = csrf_token();

return "<meta name=\"csrf-token\" content=\"{$csrfToken}\">";
});
}

Expand Down
Loading