Commit 31dbc6a1 authored by catch's avatar catch
Browse files

Issue #2151459 by jessebeach, Wim Leers, plach, amateescu, Berdir: Enable node render caching.

parent 11bf64e9
......@@ -3942,6 +3942,16 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
return '';
}
// Collect all #post_render_cache callbacks associated with this element when:
// - about to store this element in the render cache, or when;
// - about to apply #post_render_cache callbacks.
if (isset($elements['#cache']) || !$is_recursive_call) {
$post_render_cache = drupal_render_collect_post_render_cache($elements);
if ($post_render_cache) {
$elements['#post_render_cache'] = $post_render_cache;
}
}
// Add any JavaScript state information associated with the element.
if (!empty($elements['#states'])) {
drupal_process_states($elements);
......@@ -4045,16 +4055,6 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
$suffix = isset($elements['#suffix']) ? $elements['#suffix'] : '';
$elements['#markup'] = $prefix . $elements['#children'] . $suffix;
// Collect all #post_render_cache callbacks associated with this element when:
// - about to store this element in the render cache, or when;
// - about to apply #post_render_cache callbacks.
if (isset($elements['#cache']) || !$is_recursive_call) {
$post_render_cache = drupal_render_collect_post_render_cache($elements);
if ($post_render_cache) {
$elements['#post_render_cache'] = $post_render_cache;
}
}
// Cache the processed element if #cache is set.
if (isset($elements['#cache'])) {
drupal_render_cache_set($elements['#markup'], $elements);
......@@ -4127,7 +4127,7 @@ function render(&$element) {
}
if (is_array($element)) {
show($element);
return drupal_render($element);
return drupal_render($element, TRUE);
}
else {
// Safe-guard for inappropriate use of render() on flat variables: return
......@@ -4403,8 +4403,19 @@ function _drupal_render_process_post_render_cache(array &$elements) {
* elements. This allows drupal_render() to execute all of them when the element
* is retrieved from the render cache.
*
* @param array $elements
* The element to collect #post_render_cache from.
* Note: the theme system may render child elements directly (e.g. rendering a
* node causes its template to be rendered, which causes the node links to be
* drupal_render()ed). On top of that, the theme system transforms render arrays
* into HTML strings. These two facts combined imply that it is impossible for
* #post_render_cache callbacks to bubble up to the root of the render array.
* Therefore, drupal_render_collect_post_render_cache() must be called *before*
* #theme callbacks, so that it has a chance to examine the full render array.
* In short: in order to examine the full render array for #post_render_cache
* callbacks, it must use post-order tree traversal, whereas drupal_render()
* itself uses pre-order tree traversal.
*
* @param array &$elements
* The element to collect #post_render_cache callbacks for.
* @param array $callbacks
* Internal use only. The #post_render_callbacks array so far.
* @param bool $is_root_element
......@@ -4416,18 +4427,37 @@ function _drupal_render_process_post_render_cache(array &$elements) {
* @see drupal_render()
* @see _drupal_render_process_post_render_cache()
*/
function drupal_render_collect_post_render_cache(array $elements, array $callbacks = array(), $is_root_element = TRUE) {
// Collect all #post_render_cache for this element.
function drupal_render_collect_post_render_cache(array &$elements, array $callbacks = array(), $is_root_element = TRUE) {
// Try to fetch the prerendered element from cache, to determine
// #post_render_cache callbacks for this element and all its children. If we
// don't do this, then the #post_render_cache tokens will be re-generated, but
// they would no longer match the tokens in the render cached markup, causing
// the render cache placeholder markup to be sent to the end user!
$retrieved_from_cache = FALSE;
if (!$is_root_element && isset($elements['#cache'])) {
$cached_element = drupal_render_cache_get($elements);
if ($cached_element !== FALSE && isset($cached_element['#post_render_cache'])) {
$elements['#post_render_cache'] = $cached_element['#post_render_cache'];
$retrieved_from_cache = TRUE;
}
}
// If this is a render cache placeholder that hasn't been rendered yet, then
// render it now, because we must be able to collect its #post_render_cache
// callback.
if (!isset($elements['#post_render_cache']) && isset($elements['#type']) && $elements['#type'] === 'render_cache_placeholder') {
$elements = drupal_pre_render_render_cache_placeholder($elements);
}
// Collect all #post_render_cache callbacks for this element.
if (isset($elements['#post_render_cache'])) {
$callbacks = NestedArray::mergeDeep($callbacks, $elements['#post_render_cache']);
}
// Child elements that have #cache set will already have collected all their
// children's #post_render_cache callbacks, so no need to traverse further.
if (!$is_root_element && isset($elements['#cache'])) {
return $callbacks;
}
else if ($children = element_children($elements)) {
// Collect the #post_render_cache callbacks for all child elements, unless
// we've already collected them above by retrieving this element (and its
// children) from the render cache.
if (!$retrieved_from_cache && $children = element_children($elements)) {
foreach ($children as $child) {
$callbacks = drupal_render_collect_post_render_cache($elements[$child], $callbacks, FALSE);
}
......
......@@ -11,6 +11,7 @@
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Language\Language;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\entity\Entity\EntityViewDisplay;
use Symfony\Component\DependencyInjection\ContainerInterface;
......@@ -147,7 +148,7 @@ protected function getBuildDefaults(EntityInterface $entity, $view_mode, $langco
// Cache the rendered output if permitted by the view mode and global entity
// type configuration.
if ($this->isViewModeCacheable($view_mode) && !$entity->isNew() && !isset($entity->in_preview) && $this->entityType->isRenderCacheable()) {
if ($this->isViewModeCacheable($view_mode) && !$entity->isNew() && $entity->isDefaultRevision() && $this->entityType->isRenderCacheable()) {
$return['#cache'] = array(
'keys' => array('entity_view', $this->entityTypeId, $entity->id(), $view_mode),
'granularity' => DRUPAL_CACHE_PER_ROLE,
......@@ -157,6 +158,10 @@ protected function getBuildDefaults(EntityInterface $entity, $view_mode, $langco
$this->entityTypeId => array($entity->id()),
),
);
if ($entity instanceof TranslatableInterface && count($entity->getTranslationLanguages()) > 1) {
$return['#cache']['keys'][] = $langcode;
}
}
return $return;
......
......@@ -18,6 +18,7 @@
use Drupal\field\FieldConfigInterface;
use Drupal\file\FileInterface;
use Drupal\user\EntityOwnerInterface;
use Drupal\node\NodeInterface;
/**
* Comments are displayed in a flat list - expanded.
......@@ -255,7 +256,6 @@ function comment_field_instance_config_create(FieldInstanceConfigInterface $inst
*/
function comment_field_instance_config_update(FieldInstanceConfigInterface $instance) {
if ($instance->getType() == 'comment') {
\Drupal::entityManager()->getViewBuilder($instance->entity_type)->resetCache();
// Comment field settings also affects the rendering of *comment* entities,
// not only the *commented* entities.
\Drupal::entityManager()->getViewBuilder('comment')->resetCache();
......@@ -409,44 +409,44 @@ function comment_entity_view_alter(&$build, EntityInterface $entity, EntityViewD
}
/**
* Implements hook_entity_view().
* Implements hook_node_links_alter().
*/
function comment_entity_view(EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode, $langcode) {
if ($entity->getEntityTypeId() != 'node') {
// Comment links are only added to node entity type for backwards
// compatibility. Should you require comment links for other entity types
// you can do-so by implementing a new field formatter.
// @todo Make this configurable from the formatter see
// http://drupal.org/node/1901110
return;
}
function comment_node_links_alter(array &$node_links, NodeInterface $node, array &$context) {
// Comment links are only added to node entity type for backwards
// compatibility. Should you require comment links for other entity types you
// can do so by implementing a new field formatter.
// @todo Make this configurable from the formatter see
// http://drupal.org/node/1901110
$view_mode = $context['view_mode'];
if ($view_mode == 'search_index' || $view_mode == 'search_result' || $view_mode == 'print') {
// Do not add any links if the entity displayed for:
// Do not add any links if the node displayed for:
// - search indexing.
// - constructing a search result excerpt.
// - print.
return;
}
$fields = \Drupal::service('comment.manager')->getFields('node');
foreach ($fields as $field_name => $detail) {
// Skip fields that entity does not have.
if (!$entity->hasField($field_name)) {
// Skip fields that the node does not have.
if (!$node->hasField($field_name)) {
continue;
}
$links = array();
$commenting_status = $entity->get($field_name)->status;
$commenting_status = $node->get($field_name)->status;
if ($commenting_status) {
$instance = \Drupal::service('field.info')->getInstance('node', $entity->bundle(), $field_name);
// Entity have commenting open or close.
$instance = \Drupal::service('field.info')->getInstance('node', $node->bundle(), $field_name);
// Node have commenting open or close.
if ($view_mode == 'rss') {
// Add a comments RSS element which is a URL to the comments of this node.
$options = array(
'fragment' => 'comments',
'absolute' => TRUE,
);
$entity->rss_elements[] = array(
$node->rss_elements[] = array(
'key' => 'comments',
'value' => $entity->url('canonical', $options),
'value' => $node->url('canonical', $options),
);
}
elseif ($view_mode == 'teaser') {
......@@ -454,13 +454,13 @@ function comment_entity_view(EntityInterface $entity, EntityViewDisplayInterface
// or a link to add new comments if the user has permission, the node
// is open to new comments, and there currently are none.
if (user_access('access comments')) {
if (!empty($entity->get($field_name)->comment_count)) {
if (!empty($node->get($field_name)->comment_count)) {
$links['comment-comments'] = array(
'title' => format_plural($entity->get($field_name)->comment_count, '1 comment', '@count comments'),
'title' => format_plural($node->get($field_name)->comment_count, '1 comment', '@count comments'),
'attributes' => array('title' => t('Jump to the first comment of this posting.')),
'fragment' => 'comments',
'html' => TRUE,
) + $entity->urlInfo();
) + $node->urlInfo();
if (\Drupal::moduleHandler()->moduleExists('history')) {
$links['comment-new-comments'] = array(
'title' => '',
......@@ -468,7 +468,7 @@ function comment_entity_view(EntityInterface $entity, EntityViewDisplayInterface
'attributes' => array(
'class' => 'hidden',
'title' => t('Jump to the first new comment of this posting.'),
'data-history-node-last-comment-timestamp' => $entity->get($field_name)->last_comment_timestamp,
'data-history-node-last-comment-timestamp' => $node->get($field_name)->last_comment_timestamp,
'data-history-node-field-name' => $field_name,
),
'html' => TRUE,
......@@ -488,32 +488,32 @@ function comment_entity_view(EntityInterface $entity, EntityViewDisplayInterface
if ($comment_form_location == COMMENT_FORM_SEPARATE_PAGE) {
$links['comment-add']['route_name'] = 'comment.reply';
$links['comment-add']['route_parameters'] = array(
'entity_type' => $entity->getEntityTypeId(),
'entity_id' => $entity->id(),
'entity_type' => $node->getEntityTypeId(),
'entity_id' => $node->id(),
'field_name' => $field_name,
);
}
else {
$links['comment-add'] += $entity->urlInfo();
$links['comment-add'] += $node->urlInfo();
}
}
else {
$links['comment-forbidden'] = array(
'title' => \Drupal::service('comment.manager')->forbiddenMessage($entity, $field_name),
'title' => \Drupal::service('comment.manager')->forbiddenMessage($node, $field_name),
'html' => TRUE,
);
}
}
}
else {
// Entity in other view modes: add a "post comment" link if the user is
// allowed to post comments and if this entity is allowing new comments.
// Node in other view modes: add a "post comment" link if the user is
// allowed to post comments and if this node is allowing new comments.
if ($commenting_status == COMMENT_OPEN) {
$comment_form_location = $instance->getSetting('form_location');
if (user_access('post comments')) {
// Show the "post comment" link if the form is on another page, or
// if there are existing comments that the link will skip past.
if ($comment_form_location == COMMENT_FORM_SEPARATE_PAGE || (!empty($entity->get($field_name)->comment_count) && user_access('access comments'))) {
if ($comment_form_location == COMMENT_FORM_SEPARATE_PAGE || (!empty($node->get($field_name)->comment_count) && user_access('access comments'))) {
$links['comment-add'] = array(
'title' => t('Add new comment'),
'attributes' => array('title' => t('Share your thoughts and opinions related to this posting.')),
......@@ -522,19 +522,19 @@ function comment_entity_view(EntityInterface $entity, EntityViewDisplayInterface
if ($comment_form_location == COMMENT_FORM_SEPARATE_PAGE) {
$links['comment-add']['route_name'] = 'comment.reply';
$links['comment-add']['route_parameters'] = array(
'entity_type' => $entity->getEntityTypeId(),
'entity_id' => $entity->id(),
'entity_type' => $node->getEntityTypeId(),
'entity_id' => $node->id(),
'field_name' => $field_name,
);
}
else {
$links['comment-add'] += $entity->urlInfo();
$links['comment-add'] += $node->urlInfo();
}
}
}
else {
$links['comment-forbidden'] = array(
'title' => \Drupal::service('comment.manager')->forbiddenMessage($entity, $field_name),
'title' => \Drupal::service('comment.manager')->forbiddenMessage($node, $field_name),
'html' => TRUE,
);
}
......@@ -542,21 +542,23 @@ function comment_entity_view(EntityInterface $entity, EntityViewDisplayInterface
}
}
$entity->content['links']['comment__' . $field_name] = array(
'#theme' => 'links__entity__comment__' . $field_name,
'#links' => $links,
'#attributes' => array('class' => array('links', 'inline')),
);
if ($view_mode == 'teaser' && \Drupal::moduleHandler()->moduleExists('history') && \Drupal::currentUser()->isAuthenticated()) {
$entity->content['links']['#attached']['library'][] = array('comment', 'drupal.node-new-comments-link');
// Embed the metadata for the "X new comments" link (if any) on this node.
$entity->content['links']['#post_render_cache']['history_attach_timestamp'] = array(
array('node_id' => $entity->id()),
);
$entity->content['links']['#post_render_cache']['Drupal\comment\CommentViewBuilder::attachNewCommentsLinkMetadata'] = array(
array('entity_type' => $entity->getEntityTypeId(), 'entity_id' => $entity->id(), 'field_name' => $field_name),
if (!empty($links)) {
$node_links['comment__' . $field_name] = array(
'#theme' => 'links__entity__comment__' . $field_name,
'#links' => $links,
'#attributes' => array('class' => array('links', 'inline')),
);
if ($view_mode == 'teaser' && \Drupal::moduleHandler()->moduleExists('history') && \Drupal::currentUser()->isAuthenticated()) {
$node_links['comment__' . $field_name]['#attached']['library'][] = array('comment', 'drupal.node-new-comments-link');
// Embed the metadata for the "X new comments" link (if any) on this node.
$node_links['comment__' . $field_name]['#post_render_cache']['history_attach_timestamp'] = array(
array('node_id' => $node->id()),
);
$node_links['comment__' . $field_name]['#post_render_cache']['Drupal\comment\CommentViewBuilder::attachNewCommentsLinkMetadata'] = array(
array('entity_type' => $node->getEntityTypeId(), 'entity_id' => $node->id(), 'field_name' => $field_name),
);
}
}
}
}
......@@ -1320,10 +1322,10 @@ function comment_preview(CommentInterface $comment, array &$form_state) {
// always addressed by reference we ensure changes are not lost by setting
// the value back to its original state after the call to entity_view().
$field_name = $comment->getFieldName();
$original_value = $entity->get($field_name);
$entity->set($field_name, COMMENT_HIDDEN);
$original_status = $entity->get($field_name)->status;
$entity->get($field_name)->status = COMMENT_HIDDEN;
$build = entity_view($entity, 'full');
$entity->set($field_name, $original_value);
$entity->get($field_name)->status = $original_status;
}
$preview_build['comment_output_below'] = $build;
......
......@@ -37,7 +37,6 @@
* uri_callback = "comment_uri",
* fieldable = TRUE,
* translatable = TRUE,
* render_cache = FALSE,
* entity_keys = {
* "id" = "cid",
* "bundle" = "field_id",
......@@ -188,6 +187,15 @@ public static function postDelete(EntityStorageControllerInterface $storage_cont
}
}
/**
* {@inheritdoc}
*/
public function referencedEntities() {
$referenced_entities = parent::referencedEntities();
$referenced_entities[] = $this->getCommentedEntity();
return $referenced_entities;
}
/**
* {@inheritdoc}
*/
......
......@@ -642,7 +642,7 @@ function theme_field($variables) {
// Render the items.
$output .= '<div class="field-items"' . $variables['content_attributes'] . '>';
foreach ($variables['items'] as $delta => $item) {
$output .= '<div class="field-item"' . $variables['item_attributes'][$delta] . '>' . drupal_render($item) . '</div>';
$output .= '<div class="field-item"' . $variables['item_attributes'][$delta] . '>' . drupal_render($item, TRUE) . '</div>';
}
$output .= '</div>';
......
......@@ -40,7 +40,6 @@
* uri_callback = "node_uri",
* fieldable = TRUE,
* translatable = TRUE,
* render_cache = FALSE,
* entity_keys = {
* "id" = "nid",
* "revision" = "vid",
......
......@@ -195,6 +195,9 @@ public function postSave(EntityStorageControllerInterface $storage_controller, $
else {
// Invalidate the cache tag of the updated node type only.
Cache::invalidateTags(array('node_type' => $this->id()));
// Invalidate the render cache for all nodes.
\Drupal::entityManager()->getViewBuilder('node')->resetCache();
}
}
......
......@@ -58,6 +58,20 @@ public function buildContent(array $entities, array $displays, $view_mode, $lang
}
}
/**
* {@inheritdoc}
*/
protected function getBuildDefaults(EntityInterface $entity, $view_mode, $langcode) {
$defaults = parent::getBuildDefaults($entity, $view_mode, $langcode);
// Don't cache nodes that are in 'preview' mode.
if (isset($defaults['#cache']) && isset($entity->in_preview)) {
unset($defaults['#cache']);
}
return $defaults;
}
/**
* #post_render_cache callback; replaces the placeholder with node links.
*
......
......@@ -49,7 +49,8 @@ function setUp() {
*/
public function testNodeDelete() {
$author = $this->drupalCreateUser();
$node_path = 'node/' . $this->drupalCreateNode(array('uid' => $author->id()))->id();
$node_id = $this->drupalCreateNode(array('uid' => $author->id()))->id();
$node_path = 'node/' . $node_id;
// Populate page cache.
$this->drupalGet($node_path);
......@@ -58,7 +59,7 @@ public function testNodeDelete() {
$cid_parts = array(url($node_path, array('absolute' => TRUE)), 'html');
$cid = sha1(implode(':', $cid_parts));
$cache_entry = \Drupal::cache('page')->get($cid);
$this->assertIdentical($cache_entry->tags, array('content:1', 'user:' . $author->id(), 'filter_format:plain_text'));
$this->assertIdentical($cache_entry->tags, array('content:1', 'node_view:' . $node_id, 'node:' . $node_id, 'user:' . $author->id(), 'filter_format:plain_text'));
// Login and delete the node.
$this->drupalLogin($this->adminUser);
......
......@@ -134,6 +134,7 @@ public function testRowPlugin() {
// Test with links disabled.
$view->rowPlugin->options['links'] = FALSE;
\Drupal::entityManager()->getViewBuilder('node')->resetCache();
$output = $view->preview();
$output = drupal_render($output);
$this->drupalSetContent($output);
......@@ -143,6 +144,7 @@ public function testRowPlugin() {
// Test with links enabled.
$view->rowPlugin->options['links'] = TRUE;
\Drupal::entityManager()->getViewBuilder('node')->resetCache();
$output = $view->preview();
$output = drupal_render($output);
$this->drupalSetContent($output);
......
......@@ -46,27 +46,29 @@ function setUp() {
function testPageCacheTags() {
// Create two nodes.
$author_1 = $this->drupalCreateUser();
$node_1_path = 'node/' . $this->drupalCreateNode(array(
$node_1 = $this->drupalCreateNode(array(
'uid' => $author_1->id(),
'title' => 'Node 1',
'body' => array(
0 => array('value' => 'Body 1', 'format' => 'basic_html'),
),
'promote' => NODE_PROMOTED,
))->id();
));
$author_2 = $this->drupalCreateUser();
$node_2_path = 'node/' . $this->drupalCreateNode(array(
$node_2 = $this->drupalCreateNode(array(
'uid' => $author_2->id(),
'title' => 'Node 2',
'body' => array(
0 => array('value' => 'Body 2', 'format' => 'full_html'),
),
'promote' => NODE_PROMOTED,
))->id();
));
// Full node page 1.
$this->verifyPageCacheTags($node_1_path, array(
$this->verifyPageCacheTags('node/' . $node_1->id(), array(
'content:1',
'node_view:1',
'node:' . $node_1->id(),
'user:' . $author_1->id(),
'filter_format:basic_html',
'menu:footer',
......@@ -74,8 +76,10 @@ function testPageCacheTags() {
));
// Full node page 2.
$this->verifyPageCacheTags($node_2_path, array(
$this->verifyPageCacheTags('node/' . $node_2->id(), array(
'content:1',
'node_view:1',
'node:' . $node_2->id(),
'user:' . $author_2->id(),
'filter_format:full_html',
'menu:footer',
......
......@@ -808,18 +808,26 @@ function testDrupalRenderRenderCachePlaceholder() {
$this->assertIdentical($settings['common_test'], $context, '#attached is modified; JavaScript setting is added to page.');
// GET request: validate cached data.
$tokens = array_keys($element['#post_render_cache']['common_test_post_render_cache_placeholder']);
$expected_token = $tokens[0];
$element = array('#cache' => array('cid' => 'render_cache_placeholder_test_GET'));
$cached_element = \Drupal::cache()->get(drupal_render_cid_create($element))->data;
// Parse unique token out of the markup.
// Parse unique token out of the cached markup.
$dom = Html::load($cached_element['#markup']);
$xpath = new \DOMXPath($dom);
$nodes = $xpath->query('//*[@token]');
$token = $nodes->item(0)->getAttribute('token');
$this->assertTrue($nodes->length, 'The token attribute was found in the cached markup');
$token = '';
if ($nodes->length) {
$token = $nodes->item(0)->getAttribute('token');
}
$this->assertIdentical($token, $expected_token, 'The tokens are identical');
// Verify the token is in the cached element.
$expected_element = array(
'#markup' => '<foo><drupal:render-cache-placeholder callback="common_test_post_render_cache_placeholder" context="bar:' . $context['bar'] .';" token="'. $token . '" /></foo>',
'#markup' => '<foo><drupal:render-cache-placeholder callback="common_test_post_render_cache_placeholder" context="bar:' . $context['bar'] .';" token="'. $expected_token . '" /></foo>',
'#post_render_cache' => array(
'common_test_post_render_cache_placeholder' => array(
$token => $context,
$expected_token => $context,
),
),
);
......@@ -840,6 +848,152 @@ function testDrupalRenderRenderCachePlaceholder() {
\Drupal::request()->setMethod($request_method);
}
/**
* Tests post-render cache-integrated 'render_cache_placeholder' child
* element.
*/
function testDrupalRenderChildElementRenderCachePlaceholder() {
$context = array('bar' => $this->randomString());
$container = array(
'#type' => 'container',
);
$test_element = array(
'#type' => 'render_cache_placeholder',
'#context' => $context,
'#callback' => 'common_test_post_render_cache_placeholder',
'#prefix' => '<foo>',
'#suffix' => '</foo>'
);
$container['test_element'] = $test_element;
$expected_output = '<div><foo><bar>' . $context['bar'] . '</bar></foo></div>';
// #cache disabled.
drupal_static_reset('_drupal_add_js');
$element = $container;
$output = drupal_render($element);
$this->assertIdentical($output, $expected_output, 'Placeholder was replaced in output');
$settings = $this->parseDrupalSettings(drupal_get_js());
$this->assertIdentical($settings['common_test'], $context, '#attached is modified; JavaScript setting is added to page.');
// The cache system is turned off for POST requests.
$request_method = \Drupal::request()->getMethod();
\Drupal::request()->setMethod('GET');
// GET request: #cache enabled, cache miss.
drupal_static_reset('_drupal_add_js');
$element = $container;
$element['#cache'] = array('cid' => 'render_cache_placeholder_test_GET');
$element['test_element']['#cache'] = array('cid' => 'render_cache_placeholder_test_child_GET');
// Simulate element rendering in a template, where sub-items of a renderable
// can be sent to drupal_render() before the parent.
$child = &$element['test_element'];
$element['#children'] = drupal_render($child, TRUE);
// Eventually, drupal_render() gets called on the root element.