Skip to content

Commit

Permalink
Refactor and fix request buffering and bundling
Browse files Browse the repository at this point in the history
  • Loading branch information
calebporzio committed Jul 2, 2023
1 parent c82bf57 commit 1bd9f94
Show file tree
Hide file tree
Showing 26 changed files with 929 additions and 729 deletions.
550 changes: 286 additions & 264 deletions dist/livewire.js

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions dist/livewire.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/manifest.json
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@

{"/livewire.js":"6407db1a"}
{"/livewire.js":"f146efaf"}
10 changes: 5 additions & 5 deletions js/$wire.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { dispatch, dispatchSelf, dispatchTo, listen } from '@/features/supportEvents'
import { generateEntangleFunction } from '@/features/supportEntangle'
import { closestComponent, findComponent } from '@/store'
import { callMethod, requestCommit } from '@/request'
import { requestCommit, requestCall } from '@/commit'
import { WeakBag, dataGet, dataSet } from '@/utils'
import { on, trigger } from '@/events'
import Alpine from 'alpinejs'
Expand Down Expand Up @@ -58,7 +58,7 @@ wireProperty('set', (component) => async (property, value, live = true) => {
dataSet(component.reactive, property, value)

return live
? await requestCommit(component.symbol)
? await requestCommit(component)
: Promise.resolve()
})

Expand Down Expand Up @@ -103,8 +103,8 @@ wireProperty('$watch', (component) => (path, callback) => {

wireProperty('$watchEffect', (component) => (callback) => effect(callback))

wireProperty('$refresh', (component) => async () => await requestCommit(component.symbol))
wireProperty('$commit', (component) => async () => await requestCommit(component.symbol))
wireProperty('$refresh', (component) => async () => await requestCommit(component))
wireProperty('$commit', (component) => async () => await requestCommit(component))

let overriddenMethods = new WeakMap

Expand Down Expand Up @@ -136,7 +136,7 @@ wireFallback((component) => (property) => async (...params) => {
}
}

return await callMethod(component.symbol, property, params)
return await requestCall(component, property, params)
})

let parentMemo
Expand Down
297 changes: 297 additions & 0 deletions js/_request.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import { reactive as r, effect as e, toRaw as tr, stop as s, pauseTracking, enableTracking } from '@vue/reactivity'
import { dataGet, dataSet, each, deeplyEqual, isObjecty, deepClone, diff, isObject } from '@/utils'
import { showHtmlModal } from './modal'
import { on, trigger } from '@/events'
import Alpine from 'alpinejs'

/**
* We'll store all our "synthetic" instances in a single lookup so that
* we can pass around an identifier, rather than the actual instance.
*/
export let store = new Map

let uri = document.querySelector('[data-uri]').getAttribute('data-uri')

export async function callMethod(symbol, method, params) {
let result = await requestMethodCall(symbol, method, params)

return result
}

let requestTargetQueue = new Map

function requestMethodCall(symbol, method, params) {
requestCommit(symbol)

return new Promise((resolve, reject) => {
let queue = requestTargetQueue.get(symbol)

let path = ''

queue.calls.push({
path,
method,
params,
handleReturn(value) {
resolve(value)
},
})
})
}

/**
* The term "commit" here refers to anytime we're making a network
* request, updating the server, and generating a new snapshot.
* We're "requesting" a new commit rather than executing it
* immediately, because we might want to batch multiple
* simultaneus commits from other synthetic targets.
*/
export function requestCommit(symbol) {
if (! requestTargetQueue.has(symbol)) {
requestTargetQueue.set(symbol, {
calls: [],
receivers: [],
resolvers: [],
handleResponse() {
this.resolvers.forEach(i => i())
}
})
}

triggerSend()

return new Promise((resolve, reject) => {
let queue = requestTargetQueue.get(symbol)

queue.resolvers.push(resolve)
})
}

let requestBufferTimeout

/**
* This is sort of "debounce" so that multiple
* network requests can be bundled together.
*/
function triggerSend() {
if (requestBufferTimeout) return

requestBufferTimeout = setTimeout(() => {
sendMethodCall()

requestBufferTimeout = undefined
}, 5)
}

/**
* This method prepares the network request payload and makes
* the actual request to the server to update the target,
* store a new snapshot, and handle any side effects.
*
* This method should fire the following events:
* - request.prepare
* - request
*/
on('commit', target => {
console.log(performance.now(), 'sent', target.encodedSnapshot)

return ({ snapshot }) => {
console.log(performance.now(), 'received', snapshot)
}
})

async function sendMethodCall() {
requestTargetQueue.forEach((request, symbol) => {
let target = store.get(symbol)

trigger('request.prepare', target)
})

let payload = []
let successReceivers = []
let failureReceivers = []

requestTargetQueue.forEach((request, symbol) => {
let target = store.get(symbol)

let propertiesDiff = diff(target.canonical, target.ephemeral)

let targetPayload = {
snapshot: target.encodedSnapshot,
updates: propertiesDiff,
calls: request.calls.map(i => ({
path: i.path,
method: i.method,
params: i.params,
}))
}

payload.push(targetPayload)

let finishTarget = trigger('request', target, targetPayload)

failureReceivers.push(() => {
let failed = true

finishTarget(failed)
})

successReceivers.push((snapshot, effects) => {
target.mergeNewSnapshot(snapshot, effects)

processEffects(target, target.effects)

if (effects['returns']) {
let returns = effects['returns']

// Here we'll match up returned values with their method call handlers. We need to build up
// two "stacks" of the same length and walk through them together to handle them properly...
let returnHandlerStack = request.calls.map(({ handleReturn }) => (handleReturn))

returnHandlerStack.forEach((handleReturn, index) => {
handleReturn(returns[index])
})
}

finishTarget({ snapshot, effects })

request.handleResponse()
})
})

requestTargetQueue.clear()

let options = {
method: 'POST',
body: JSON.stringify({
_token: getCsrfToken(),
components: payload,
}),
headers: {
'Content-type': 'application/json',
'X-Livewire': '',
},
}

let finishProfile = trigger('profile.request', options)

let finishFetch = trigger('fetch', uri, options)

let response = await fetch(uri, options)

response = finishFetch(response)

let succeed = async (responseContent) => {
let { components } = JSON.parse(responseContent)

for (let i = 0; i < components.length; i++) {
let { snapshot, effects } = components[i];

successReceivers[i](snapshot, effects)
}
}

let fail = async () => {
for (let i = 0; i < failureReceivers.length; i++) {
failureReceivers[i]();
}

let failed = true
}

await handleResponse(response, succeed, fail, finishProfile)
}

/**
* Post requests in Laravel require a csrf token to be passed
* along with the payload. Here, we'll try and locate one.
*/
export function getCsrfToken() {
if (document.querySelector('[data-csrf]')) {
return document.querySelector('[data-csrf]').getAttribute('data-csrf')
}

throw 'Livewire: No CSRF token detected'
}

/**
* Here we'll take the new state and side effects from the
* server and use them to update the existing data that
* users interact with, triggering reactive effects.
*/

export function processEffects(target, effects) {
trigger('effects', target, effects)
}

export async function handleResponse(response, succeed, fail, finishProfile) {
let content = await response.text()

if (response.ok) {
/**
* Sometimes a redirect happens on the backend outside of Livewire's control,
* for example to a login page from a middleware, so we will just redirect
* to that page.
*/
if (response.redirected) {
window.location.href = response.url
}

/**
* Sometimes a response will be prepended with html to render a dump, so we
* will seperate the dump html from Livewire's JSON response content and
* render the dump in a modal and allow Livewire to continue with the
* request.
*/
if (contentIsFromDump(content)) {
[dump, content] = splitDumpFromContent(content)

showHtmlModal(dump)

finishProfile({ content: '{}', failed: true })
} else {
finishProfile({ content, failed: false })
}

return await succeed(content)
}

finishProfile({ content: '{}', failed: true })

let skipDefault = false

trigger('response.error', response, content, () => skipDefault = true)

if (skipDefault) return await fail()

if (response.status === 419) {
handlePageExpiry()

return await fail()
}

handleFailure(content)

await fail()
}

export function contentIsFromDump(content) {
return !! content.match(/<script>Sfdump\(".+"\)<\/script>/)
}

function splitDumpFromContent(content) {
let dump = content.match(/.*<script>Sfdump\(".+"\)<\/script>/s)
return [dump, content.replace(dump, '')]
}

function handlePageExpiry() {
confirm(
'This page has expired.\nWould you like to refresh the page?'
) && window.location.reload()
}

function handleFailure(content) {
let html = content

showHtmlModal(html)
}
Loading

0 comments on commit 1bd9f94

Please sign in to comment.