General Technique for Dynamic Navigation

Hello!

I’ve been doing C and Java for many years but recently I have become more serious about web development and I’m curious about general techniques with respect to security, extensibility and performance.

Specifically, I’m currently using the following strategy for navigation using AJAX:

<html>
<head>
function xhr(el, sel) {

    // install responseText into selected element and register click

    var fe = closest(el, 'form');

    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function() {
        if (this.readyState == 4 && this.status == 200) {
            var es = closest(fe, sel);
            var ep = es.parentNode;
            es.insertAdjacentHTML('beforebegin', xhr.responseText);
            ep.removeChild(es);
            regcl(ep);
        }   
    };  

    fe.querySelector('input[name=cmd]').value = el.cmd;

    xhr.open("POST", "/myapp", true);
    xhr.send(new FormData(fe)); 
}
function cmdhdlr(el) {

    // application specific JavaScript goes here

    if (el.cmd == 'foo') {
        xhr(el, 'foo_here');
    }   
}
function regcl(el) {

    // register click for all elements with class beginning with 'cl_'

    var nlist = el.querySelectorAll('[class^=cl_]');
    var ni; 
    for (ni in nlist) {
        var n = nlist[ni];
        if (n.className) {
            var mat = n.className.match(/cl_([_a-z0-9]+)/);
            if (mat) {
                n.cmd = mat[1];
                n.addEventListener('click', cmdhdlr);
            }   
        }   
    }   
}
window.onload = function() {
    regcl(document);
}
</head>
<body>
<form>
<input type='hidden' name='cmd'>

<div class='foo_here'>
<input type='text' name='bar'>
<a class='cl_foo' id='#'>submit</a>
</div>

</form>
</body>
</html>

When the user clicks ‘submit’ (or any link that has a class that begins with ‘cl_’) cmdhdlr() is invoked which submits the form, installs the responseText into the dom indicated by the selector and calls regcl() on that new fragment to register any links in it. The idea is that since XHR reponses cannot contain scripts, regcl() will just look for any nodes with a class that begins with ‘cl_’ and register those for click events. This is somewhat simplified of course but you get the idea.

Is this a typcical technique? Are there any security issues here? If some can inject content into the XHR reponse they will only be able to call code that is pre-defined in cmdhdlr() so it doesn’t seem like a problem to me.

Are there any holes in this?

  1. JS Code should be in tag [script]. Otherwise it will be shown just as plain text.

  2. Better to use external JS-script, than inline script.

Hi @ioplex, the typical technique would be not to send any markup in the AJAX response at all, but just the data (most commonly as JSON) and then modify the DOM accordingly on the client side; you can then add event listeners directly while creating new elements. There’s actually a current discussion on this topic here you might check out.

BTW also note that the selector [class^=cl_] will only match elements where the first class in the list starts with cl_ – elements with say class="foo cl_bar" will not be found.

I am not sure but there is probably a better way to do it than using readyState and status in onreadystatechange. I would look for a more appropriate method than onreadystatechange.

Ok, so I have adjusted my technique to use JSON as described in that post.

Here is my current simple example app. Again, I’ve beeing doing Java and C and similar for years so I’m looking for all comments, improvements, fixes, recommendations, performance enhancements and general criticism.

This simple example just looks like this:
simplex
This is one form that should just echo the data when one clicks Next. I think this follows a typical form navigation - form is echoed until it passes whatever tests for completeness / validation before being committed / acted apon.

So here’s the code. The initial page is:

<!DOCTYPE html>
<html>
<head>
<script type='text/javascript'>
function evreg(el, perm, hdlr) {
	const elist = el.querySelectorAll('[class^=evreg_]');
	for (const ell of elist) {
		if (ell.className) {
			const mat = ell.className.match(/evreg_([a-z]+)_([a-z0-9_]+)/);
			if (mat && perm.includes(mat[1])) {
				ell.cmd = mat[2];
				ell.addEventListener(mat[1], hdlr);
			}
		}
	}
}
function tmpl_render(tmplid, data) {
	const tmpl = document.getElementById(tmplid).innerHTML;
	const toks = tmpl.match(/[{}]|[^{}]+/g);
	let state = 0;
	let sub = null;
	let ret = '';
	for (tok of toks) {
		switch (state) {
			case 0:
				if (tok == '{') {
					state = 1;
				} else {
					ret += tok;
				}
				break;
			case 1:
				if (data.hasOwnProperty(tok)) {
					sub = tok;
					state = 2;
				} else {
					ret += '{' + tok;
					state = 0;
				}
				break;
			case 2:
				if (tok == '}') {
					sub = data[sub];
					if (typeof sub === 'function') {
						ret += sub(data);
					} else {
						ret += sub.toString();
					}
				} else {
					ret += '{' + sub + tok;
				}
				state = 0;
				break;
		}
	}
	return ret;
}
function form_toobj(form) {
	let ret = {};

	const _traverse = function(el, obj) {
		switch (el.tagName) {
			case 'SELECT':
				if (el.multiple) {
					const options = el.options;
					let ovals = [];
					for (let oi = 0; oi < options.length; oi++) {
						const opt = options[oi];
						if (opt.selected) {
							ovals.push(opt.value || opt.text);
						}
					}
					obj[el.name] = ovals;
				} else {
					obj[el.name] = el.value;
				}
				return;
			case 'INPUT':
				if (el.type === 'checkbox') {
					if (el.checked) {
						obj[el.name] = el.checked;
					}
					return;
				} else if (el.type === 'radio') {
					if (el.checked) {
						obj[el.name] = el.value;
					}
					return;
				}
			case 'TEXTAREA':
				obj[el.name] = el.value;
				return;
			case 'FIELDSET':
				obj = obj[el.name] = {};
				break;
		}

		for (let ci = 0; ci < el.children.length; ci++) {
			_traverse(el.children[ci], obj);
		}
	}

	_traverse(form, ret);

	return ret;
}
// application specific handler
function dochdlr(event) {

	if (this.cmd === 'next') {
		event.preventDefault();

		const form = closest(this, 'form');

		fetch(form.action, {
			method: 'POST',
			headers: {
				'Content-Type': 'application/json',
			},
			body: JSON.stringify({
				cmd: this.cmd,
				form: form_toobj(form),
			}),
		}).then(resp => resp.json())
			.then(jobj => {
				jobj = jobj.form;

				jobj.list_members = (jobj) => {
					let ret = '';
					for (const member of jobj.members) {
						const checked = member.checked ? member.checked : '';
						ret += tmpl_render('tmpl_member', {
							id: member.id,
							name: member.ident,
							checked: checked,
						});
					}
					return ret;
				}

				form.innerHTML = tmpl_render('tmpl_group', jobj);

				evreg(form, [ 'click' ], dochdlr);
			})
			.catch(console.error);
	}
}

window.addEventListener('load', () => {
	evreg(document, [ 'click' ], dochdlr);
});
</script>

<script id="tmpl_group" type="text/html">
<div>
<input type='text' name='group' value='{group}'>
<div>
{list_members}
</div>
<a class='evreg_click_next' href='#'>Next</a>
</div>
</script>

<script id="tmpl_member" type="text/html">
<div>
<input type='checkbox' name='n{id}'{checked}/>{name}<sup>{id}</sup>
</div>
</script>

</head>
<body>
<form>
<a class='evreg_click_next' href='#'>Next</a>
</form>
</body>
</html>

So this starts out as just a Next link. Clicking on that calls fetch and gets some JSON that is fed to a simple template function to make the form that also has a Next link. The second time Next is clicked, the form is converted to JSON and submitted.

Here is the PHP server code:

    $json = file_get_contents('php://input');
    $jobj = json_decode($json, true, 64);
    $form = &$jobj['form'];

    $rec = $this->query();
    if (isset($form['group'])) {
        $rec['group'] = $form['group'];
    }
    foreach ($rec['members'] as $mi => $member) {
        $mem = "n{$member['id']}";
        if (isset($form[$mem]) && $form[$mem] === true) { 
            $rec['members'][$mi]['checked'] = 'checked';
        }
    }

    $jobj['form'] = $rec; 

    echo json_encode($jobj);

The query() call is just a sub for a database and just returns an array:

        return [
            'group' => 'Engineering',
            'members' => [
                [ 'id' => 123, 'ident' => 'abaker' ],
                [ 'id' => 213, 'ident' => 'bcarter' ],
                [ 'id' => 132, 'ident' => 'cdavis' ],
            ],
        ];

The server side stuff seems a little clumsy to me. The incoming JSON must be fixed up and merged into the outgoing. Hopefully you have some good ideas for making this better.

I wouldn’t send the form data to the server as JSON, it’s much easier to just send the complete form as a FormData object; in the PHP script, that data will then be available via the $_POST variable (or $_GET for that matter) as if it was submitted without AJAX.

Also, rather than replacing the original form fields with new ones, you might consider using separate forms for each step… so you could for example allow navigating between the steps. Along the following lines:

<form action="submit.php" method="POST">
  <input type="hidden" name="step" value="1">
  <button>Next</button>
</form>

<form action="submit.php" method="POST" hidden>
  <input type="hidden" name="step" value="2">
  <button>Done</button>
</form>
const [ firstForm, secondForm ] = document.forms

const submitForm = form => fetch(form.action, {
  method: form.method,
  body: new FormData(form)
}).then(response => response.json())

firstForm.addEventListener('submit', event => {
  event.preventDefault()

  submitForm(firstForm).then(data => {
    // Populate the 2nd form with the data
    // where required, then switch the forms
    firstForm.hidden = true
    secondForm.hidden = false
  }).catch(console.error)
})

How do you escape data fields that might be HTML?

If JSON data is rendered on the client, then that must be where each field is escaped. Yeah, in a perfect world, this shouldn’t be necessary if your validation of input is good. But just in case someone figures out how to get illegal junk into my database, how do I escape HTML in JavaScript?

This topic was automatically closed 91 days after the last reply. New replies are no longer allowed.