# Page methods

Page methods are `@classmethod`s that receive `page` (the page class) and usually `player` (the current participant) as arguments. Standard page hooks can be sync or async; wait-page callbacks such as `after_grouping` and `all_here` are sync-only.

## Lifecycle overview

For a forward visit to a page, the lifecycle is:

1. **`early`:** Run first, whenever navigation reaches this page position.
2. **`before_always_once`:** Run once when this page position is reached.
3. **`show`:** Decide whether the page is displayed.
4. **`before_once`:** Run once, before the page is displayed for the first time.
5. **`fields`:** Determine form fields.
6. **`templatevars` and `jsvars`:** Prepare template and JS data.
7. *(The page is rendered and displayed.)*
8. *(The participant submits.)*
9. **`validate`:** Check submitted data.
10. **`stealth_fields` and `handle_stealth_fields`:** Handle manual fields.
11. **`before_form_save`:** Run before ordinary form fields are saved.
12. **`may_proceed`:** Gate before advancing.
13. **`after_once`:** Run once, after the first submission that leaves the page.
14. **`after_always_once`:** Run once when this page position is left forward.

A few rules explain all of this:

- **Entering.** Entry runs `early`, then `before_always_once`. For forward navigation and loading, `show` then decides. A page that is not shown is *skipped*: `after_always_once` runs, and uproot enters the next position. A shown page is *displayed*: `before_once` runs, then the participant sees the page. [Back](https://uproot.science/building/pages.md#allowing-back-navigation) first uses `show` to select an earlier page, then enters and displays it without checking `show` again. Staying after a submission also displays the current page without rechecking `show`.
- **Once.** `before_always_once`, `before_once`, `after_once` and `after_always_once` run the first time their occasion arises at a page position, always, but only once. `early` runs whenever navigation reaches a position; reloading the page runs it only if it has never run there.
- **Leaving.** A submission that passes all checks leaves the page if `may_proceed` allows it: `after_once`, `after_always_once`, then uproot enters the next position. Otherwise, the page is displayed again with the submitted values, and with error messages if the submission was rejected. Error messages never appear on another page.
- **Moves.** If a page method moves the participant (with `move_to_page` or `move_to_end`), the method and the others of the same step (entering, `show`, skipping, displaying, leaving) finish, and then uproot enters the target. End cleanup waits for that step too. A move to the end outside a page request runs cleanup immediately, even if the participant never returns.
- **Other forms.** A request that does not carry the form of the participant’s current page follows the loading path, and its form fields are not automatically saved. Hooks retain the actual HTTP request: `early` can still distinguish a POST from a GET. This happens, for example, when a participant submits a page just after an admin advanced them, or clicks Next twice and the first click already took them to the next page.

The `timeout` method configures the deadline while the page is rendered. The browser submits when the deadline is reached. If the server receives a request after the deadline, `timeout_reached` handles that request before normal form processing, `may_proceed` is skipped, and the page advances, even if the participant clicked Back. Valid form data is saved. Data that is invalid, or rejected by `validate` or `handle_stealth_fields`, is discarded without an error message. uproot counts a request up to `upd.TIMEOUT_TOLERANCE` seconds (default: 1) before the deadline as timed out, too, but only if `may_proceed` allows the page to advance. See [Timeouts and `may_proceed`](https://uproot.science/advanced/timeouts.md#timeouts-and-may_proceed).

## `show`

Decides whether the page should be displayed. Return `False` to skip the page entirely.

```python
@classmethod
def show(page, player):
    return player.role == "proposer"
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `bool`: `True` to show (default), `False` to skip |

## `early`

The earliest hook in the page lifecycle. Runs whenever navigation reaches this page position, also if the page is not shown, before `before_always_once`. Reloading the page does not run it again. Receives the HTTP request as an additional keyword argument.

```python
@classmethod
def early(page, player, request):
    player.user_agent = request.headers.get("user-agent", "")
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| `request` | The Starlette `Request` object |

## `before_once`

Runs **once per player**, the first time they see this page, also if they reach it with Back. Does not run again if the player navigates back and returns.

```python
@classmethod
def before_once(page, player):
    player.start_time = time()
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |

## `before_always_once`

Runs once when this page position is reached, also if the page is not shown. Use this for setup that should also happen for internal or skipped pages.

```python
@classmethod
def before_always_once(page, player):
    player.visit_count = getattr(player, "visit_count", 0) + 1
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |

## `fields`

Returns the form fields for this page. Can be a dictionary (static) or a method (dynamic).

### Static fields

```python
class Survey(Page):
    fields = dict(
        age=IntegerField(label="Age", min=18, max=100),
    )
```

### Dynamic fields

```python
@classmethod
def fields(page, player):
    max_offer = player.other_in_group.endowment
    return dict(
        offer=IntegerField(label="Your offer", min=0, max=max_offer),
    )
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `dict` mapping field names to field instances |

## `templatevars`

Returns variables available in the page template.

```python
@classmethod
def templatevars(page, player):
    return dict(
        partner=player.other_in_group,
        total=sum(p.contribution for p in player.group.players),
    )
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `dict` of template variables, or `None` |

`templatevars` may return `None` instead of a dict. This is treated the same as returning `{}`.

## `jsvars`

Returns variables available in JavaScript as `uproot.vars`.

```python
@classmethod
def jsvars(page, player):
    return dict(
        initial_price=player.price,
        max_trades=C.MAX_TRADES,
    )
```

Access in JavaScript:

```javascript
const price = uproot.vars.initial_price;
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `dict` of JavaScript variables |

## `validate`

Custom validation of submitted form data. Called after built-in field validation passes.

```python
@classmethod
def validate(page, player, data):
    if data["give_a"] + data["give_b"] > 100:
        return "Total cannot exceed 100"
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| `data` | `dict` of submitted values |
| **Returns** | Error message(s), or nothing if valid |

Return types are:

- `str`: single error displayed at the top of the form;
- `list[str]`: multiple errors displayed at the top;
- `dict[str, str | list[str]]`: per-field errors displayed next to each field.

```python
@classmethod
def validate(page, player, data):
    errors = {}
    if data["min_price"] > data["max_price"]:
        errors["min_price"] = "Must be less than max price"
        errors["max_price"] = "Must be greater than min price"
    return errors or None
```

[See the input_validation example](https://github.com/mrpg/uproot-examples/tree/main/input_validation)

## `before_form_save`

Runs after built-in and custom validation passes and after
[`handle_stealth_fields`](#handle_stealth_fields) (if any) accepts the
submission, immediately before ordinary form fields are saved to the player. The submitted `data` mapping is read-only;
use this hook for checks or side effects that need the validated values before
they are persisted.

```python
@classmethod
def before_form_save(page, player, data):
    player.submitted_at = time()
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| `data` | Read-only mapping of validated form values |

## `may_proceed`

Gate that controls whether the player can advance. Return `False` to keep the player on the page. It does not stop [Back](https://uproot.science/building/pages.md#allowing-back-navigation).

```python
@classmethod
def may_proceed(page, player):
    from time import time
    return time() >= player.session.detection_period_until
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `bool`: `True` to allow proceeding (default), `False` to block |

!!! note
    A page [timeout](#timeout) overrides `may_proceed` once the deadline has passed. See [Timeouts and `may_proceed`](https://uproot.science/advanced/timeouts.md#timeouts-and-may_proceed).

## `after_once`

Runs **once per player**, after the first submission that leaves the page (or after its timeout). Does not run on back navigation or revisits.

```python
@classmethod
def after_once(page, player):
    player.completed_task = True
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |

## `after_always_once`

Runs once when the player leaves this page position forward: after a submission (or a timeout), or when the page is skipped because it is not shown.

```python
@classmethod
def after_always_once(page, player):
    player.attempts += 1
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |

!!! note
    `after_once` and `after_always_once` are not available on `GroupCreatingWait` or `SynchronizingWait` pages.

## `timeout`

Sets a page timeout in seconds. Can be a static value or a method.

### Static timeout

```python
class TimedTask(Page):
    timeout = 60
```

### Dynamic timeout

```python
@classmethod
def timeout(page, player):
    return max(0, player.deadline - time())
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `float` seconds until timeout, or `None` to disable |

## `timeout_reached`

Called when the page timeout expires, before the page auto-advances. It runs before the submitted form data is processed, so valid submitted values overwrite values that `timeout_reached` sets for the same fields. See [Handling timeout expiration](https://uproot.science/advanced/timeouts.md#handling-timeout-expiration).

```python
@classmethod
def timeout_reached(page, player):
    player.timed_out = True
    player.score = 0
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |

## `stealth_fields`

Specifies which fields should not be saved automatically. Can be a list or a method.

### Static stealth fields

```python
class MyPage(Page):
    stealth_fields = ["code", "iban"]
```

### Dynamic stealth fields

```python
@classmethod
def stealth_fields(page, player):
    return ["response"] if player.condition == "special" else []
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| **Returns** | `list[str]` of field names to handle manually |

## `handle_stealth_fields`

Processes stealth fields and file uploads. Called after validation passes and before ordinary fields are saved. Return error strings to reject the submission: the participant stays on the page, and nothing from the submission is saved. Changes the handler makes to `player` itself are kept. See [Stealth fields](https://uproot.science/building/forms.md#stealth-fields).

```python
@classmethod
async def handle_stealth_fields(page, player, data):
    code = data["code"]
    if code != "secret123":
        return "Invalid access code"
    player.verified = True
```

| Parameter | Description |
|-----------|-------------|
| `page` | The page class |
| `player` | The current player |
| `data` | `dict` of all submitted field values, including stealth fields |
| **Returns** | Error string or list of error strings, or nothing if valid |

[See the payment_data example](https://github.com/mrpg/uproot-examples/tree/main/payment_data) · [input_validation example](https://github.com/mrpg/uproot-examples/tree/main/input_validation)

## Page class attributes

| Attribute | Default | Description |
|-----------|---------|-------------|
| `allow_back` | `False` | Show a "Back" button |
| `template` | Matching app `.html` file, then `.md` | Custom template path relative to the project root |
| `keep_values`{ .text-nowrap } | `False` | Re-populate form from player data on re-render |

## Wait page methods

### `GroupCreatingWait`

| Attribute/Method | Description |
|------------------|-------------|
| `group_size` | Required. Number of players per group |
| `after_grouping(page, group)` | Called once when the group forms |

### `SynchronizingWait`

| Attribute/Method | Description |
|------------------|-------------|
| `synchronize` | `"group"` (default) or `"session"` |
| `all_here(page, group)` | Called when all group members arrive |
| `all_here(page, session)` | Called when all session members arrive (if `synchronize = "session"`) |
| `wait_for(page, player)` | Override to customize who to wait for |
